@@ -14,7 +14,7 @@ import {
1414} from '@angular/core' ;
1515import { DiagramService } from "../services/diagram.service" ;
1616import { extract , ReactomeEvent , ReactomeEventTypes , Style } from "reactome-cytoscape-style" ;
17- import cytoscape , { BoundingBoxWH , ElementsDefinition } from "cytoscape" ;
17+ import cytoscape , { BoundingBox12 , BoundingBoxWH , ElementsDefinition } from "cytoscape" ;
1818import { InteractorService } from "../interactors/services/interactor.service" ;
1919import {
2020 catchError ,
@@ -134,7 +134,7 @@ export class DiagramComponent implements AfterViewInit, OnDestroy {
134134 this . updateStyle ( ) ;
135135 } )
136136
137- effect ( ( ) => {
137+ effect ( async ( ) => {
138138 const request = this . download . downloadRequest ( ) ;
139139 if ( request ) {
140140 this . export ( request . format ) ;
@@ -144,18 +144,72 @@ export class DiagramComponent implements AfterViewInit, OnDestroy {
144144
145145 }
146146
147- export ( format : string ) {
148- const options : cytoscape . ExportOptions = {
147+ async export ( format : string ) {
148+ const options : cytoscape . ExportJpgBlobPromiseOptions = {
149149 full : true ,
150- ...( format === DownloadFormat . JPEG ? { quality : 0.9 } : { } )
150+ ...( format === DownloadFormat . JPEG ? { quality : 0.9 } : { } ) ,
151+ bg : 'transparent' ,
152+ output : 'blob-promise'
153+ }
154+
155+ const blobs = this . cys . map ( cy => format === DownloadFormat . PNG ? cy . png ( options ) : cy . jpg ( options ) ) ;
156+ let blob : Blob ;
157+ if ( blobs . length > 1 ) {
158+ const images = await Promise . all ( blobs . map ( blob => blob . then ( createImageBitmap ) ) ) ;
159+ const bbs = this . cys . map ( cy => cy . elements ( ) . boundingBox ( { includeLabels : false } ) ) ;
160+ const bgColors = this . cys . map ( cy => getComputedStyle ( cy . container ( ) ! ) . backgroundColor ) ;
161+ blob = await this . mergeImages ( images , bbs , bgColors , format === DownloadFormat . JPEG ? 'image/jpeg' : 'image/png' , options ?. quality ) ;
162+ } else {
163+ blob = await blobs [ 0 ] ;
151164 }
165+
152166 const a = document . createElement ( 'a' ) ;
153- a . href = format === DownloadFormat . PNG ? this . cy . png ( options ) : this . cy . jpg ( options ) ;
167+ a . href = URL . createObjectURL ( blob ) ;
154168 a . download = `${ this . pathwayId ( ) } .${ format } ` ;
155169 a . click ( ) ;
156170 a . remove ( ) ;
157171 }
158172
173+ async mergeImages ( images : ImageBitmap [ ] , bbs : BoundingBox12 [ ] , bgColors : string [ ] , format : 'image/jpeg' | 'image/png' , quality ?: number ) : Promise < Blob > {
174+ // Compute merged canvas size in model space
175+ const xMin = Math . min ( ...bbs . map ( bb => bb . x1 ) ) ;
176+ const yMin = Math . min ( ...bbs . map ( bb => bb . y1 ) ) ;
177+ const xMax = Math . max ( ...bbs . map ( bb => bb . x2 ) ) ;
178+ const yMax = Math . max ( ...bbs . map ( bb => bb . y2 ) ) ;
179+
180+ // Calculate scale ratio between pixel space (image) and model space (bounding box)
181+ // Assume all images have the same scale - use the first one
182+ const bbWidth = bbs [ 0 ] . x2 - bbs [ 0 ] . x1 ;
183+ const scale = images [ 0 ] . width / bbWidth ;
184+
185+ const mergedWidth = ( xMax - xMin ) * scale ;
186+ const mergedHeight = ( yMax - yMin ) * scale ;
187+
188+ const canvas = document . createElement ( 'canvas' ) ;
189+ canvas . width = mergedWidth ;
190+ canvas . height = mergedHeight ;
191+ const ctx = canvas . getContext ( '2d' ) ! ;
192+ if ( format === 'image/jpeg' ) {
193+ ctx . fillStyle = this . dark . isDark ( ) ? '#000' : '#fff' ;
194+ ctx . fillRect ( 0 , 0 , mergedWidth , mergedHeight ) ;
195+ }
196+
197+ images . forEach ( ( image , i ) => {
198+ const offsetX = ( bbs [ i ] . x1 - xMin ) * scale ; // shift relative to merged bbox, scaled to pixel space
199+ const offsetY = ( bbs [ i ] . y1 - yMin ) * scale ;
200+
201+ // Fill background color for this layer
202+ ctx . fillStyle = bgColors [ i ] ;
203+ ctx . fillRect ( 0 , 0 , mergedWidth , mergedHeight ) ;
204+
205+ // Draw image on top
206+ ctx . drawImage ( image , offsetX , offsetY ) ;
207+ image . close ( ) ;
208+ } ) ;
209+
210+ return new Promise ( resolve => canvas . toBlob ( blob => resolve ( blob ! ) , format , quality ) ) ;
211+ }
212+
159213 zoomToCytoscapeTransform = ( x : number ) => this . minZoom ( ) * Math . pow ( this . maxZoom ( ) / this . minZoom ( ) , ( x - this . controlMinZoom ( ) ) / this . controlRange ( ) ) ;
160214 zoomToControlTransform = ( zoomCy : number ) => this . controlMinZoom ( ) + this . controlRange ( ) * ( Math . log ( zoomCy / this . minZoom ( ) ) / Math . log ( this . maxZoom ( ) / this . minZoom ( ) ) ) ;
161215 thumbnailImg = signal < string > ( '' ) ;
0 commit comments