cytoscape-canvas-underlay 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@ A [Cytoscape.js](https://js.cytoscape.org) plugin for rendering image/PDF backgr
4
4
 
5
5
  - **Image & PDF support** — load `.png`, `.jpg`, `.svg`, or `.pdf` as background
6
6
  - **Multiple drawings** — overlay additional drawings at arbitrary world positions
7
- - **Zoom/pan sync** — synchronous redraw on every viewport event for zero-lag rendering
7
+ - **Zoom/pan sync** — viewport-synced redraw, coalesced to one draw per frame (requestAnimationFrame) for smooth pan/zoom
8
+ - **Large-image performance** — optional downscaled proxy during pan/zoom for huge backgrounds, full resolution on idle (`lowQualityOnInteraction`); CSS filters are pre-baked once instead of per frame
8
9
  - **Pan clamping** — hard boundary or iOS-style rubber-band with spring-back
9
10
  - **Minimap** — DOM-based crisp image rendering, two viewport styles (`dim` / `rect`), auto-hide, full customization
10
11
  - **Adaptive PDF quality** — low-quality render during interaction, high-quality on idle
@@ -273,6 +274,15 @@ const inside = api.isPointInDrawing(world.x, world.y);
273
274
  | `drawingVisible` | `boolean` | `true` | Initial drawing visibility |
274
275
  | `graphVisible` | `boolean` | `true` | Initial graph layer visibility |
275
276
 
277
+ ### Interaction Quality
278
+
279
+ For very large drawings (e.g. tens of megapixels), redrawing the full-resolution image on every pan/zoom frame can cause stutter. Enable `lowQualityOnInteraction` to draw a downscaled proxy during interaction and restore full resolution once it settles.
280
+
281
+ | Option | Type | Default | Description |
282
+ |--------|------|---------|-------------|
283
+ | `lowQualityOnInteraction` | `boolean` | `false` | Draw a downscaled proxy + lower image smoothing during pan/zoom, restoring full quality when interaction stops. Recommended for large background images. |
284
+ | `interactionProxyMax` | `number` | `2560` | Max edge (px) of the downscaled proxy used while interacting. Only sources larger than this are downscaled. |
285
+
276
286
  ### PDF Quality
277
287
 
278
288
  | Option | Type | Default | Description |
package/package.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "name": "cytoscape-canvas-underlay",
3
- "version": "1.3.0",
4
- "description": "Cytoscape.js plugin for rendering image/PDF canvas underlay behind graph nodes with synchronized zoom and pan",
5
- "main": "src/index.js",
6
- "module": "src/index.js",
7
- "type": "module",
8
- "exports": {
9
- ".": {
10
- "import": "./src/index.js",
11
- "default": "./src/index.js"
12
- }
13
- },
14
- "files": [
15
- "src",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "keywords": [
20
- "cytoscape",
21
- "cytoscape-extension",
22
- "cytoscape-plugin",
23
- "canvas",
24
- "underlay",
25
- "background",
26
- "drawing",
27
- "pdf",
28
- "image",
29
- "p&id",
30
- "diagram"
31
- ],
32
- "author": "",
33
- "license": "MIT",
34
- "peerDependencies": {
35
- "cytoscape": "^3.2.0",
36
- "pdfjs-dist": "^4.0.0 || ^5.0.0"
37
- },
38
- "peerDependenciesMeta": {
39
- "pdfjs-dist": {
40
- "optional": true
41
- }
42
- },
43
- "repository": {
44
- "type": "git",
45
- "url": ""
46
- },
47
- "homepage": ""
48
- }
1
+ {
2
+ "name": "cytoscape-canvas-underlay",
3
+ "version": "1.4.0",
4
+ "description": "Cytoscape.js plugin for rendering image/PDF canvas underlay behind graph nodes with synchronized zoom and pan",
5
+ "main": "src/index.js",
6
+ "module": "src/index.js",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "cytoscape",
21
+ "cytoscape-extension",
22
+ "cytoscape-plugin",
23
+ "canvas",
24
+ "underlay",
25
+ "background",
26
+ "drawing",
27
+ "pdf",
28
+ "image",
29
+ "p&id",
30
+ "diagram"
31
+ ],
32
+ "author": "",
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "cytoscape": "^3.2.0",
36
+ "pdfjs-dist": "^4.0.0 || ^5.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "pdfjs-dist": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": ""
46
+ },
47
+ "homepage": ""
48
+ }
@@ -35,6 +35,12 @@ const DEFAULTS = {
35
35
  drawingVisible: true, // Show/hide the background drawing
36
36
  graphVisible: true, // Show/hide the cytoscape graph layer
37
37
 
38
+ // ── Interaction Quality ──
39
+ // pan/zoom 중 큰 도면을 다운스케일 프록시 + 저품질 스무딩으로 그려 끊김을 줄인다(멈추면 풀해상도 복원).
40
+ // 큰 배경 이미지에서 pan/zoom 이 끊길 때 true 로 사용.
41
+ lowQualityOnInteraction: false, // pan/zoom 중 저품질 렌더 사용 여부
42
+ interactionProxyMax: 2560, // 프록시 최대 변(px). 이보다 큰 소스만 축소.
43
+
38
44
  // ── PDF Quality ──
39
45
  qualityDelay: 100, // ms delay before high-quality PDF re-render
40
46
  pdfMinRenderSize: 2048, // Minimum PDF render dimension (px)
@@ -207,11 +213,18 @@ export class DrawingOverlay {
207
213
  // Additional drawings
208
214
  this._drawings = new Map();
209
215
 
216
+ // PDF 존재 여부 캐시 (zoom/pan마다 순회 방지)
217
+ this._hasPdfCached = false;
218
+
210
219
  // RAF / debounce
211
220
  this._rafId = null;
212
221
  this._qualityTimer = null;
213
222
  this._abortController = null;
214
223
 
224
+ // pan/zoom 중 저품질 렌더 플래그 + 멈춤 감지 타이머
225
+ this._interacting = false;
226
+ this._interactTimer = null;
227
+
215
228
  // Pan clamping re-entry guard
216
229
  this._isPanAdjusting = false;
217
230
 
@@ -293,17 +306,17 @@ export class DrawingOverlay {
293
306
  _bindEvents() {
294
307
  this._onViewport = () => {
295
308
  this._enforceLimits();
296
- this._draw();
297
- if (this._minimap) this._minimap.render();
298
309
 
299
- // Schedule high-quality PDF re-render at current zoom level
300
- const hasPdf = this._main.isPdf || [...this._drawings.values()].some(d => d.isPdf && d.visible);
301
- if (hasPdf) {
302
- if (this._qualityTimer) clearTimeout(this._qualityTimer);
303
- this._qualityTimer = setTimeout(() => {
304
- this._reRenderAllPdfs().then(() => this._draw());
305
- }, this.opts.qualityDelay);
306
- }
310
+ // pan/zoom 중에는 저품질로 그리고, 멈추면 idle 고품질로 한 번 더 그린다.
311
+ this._interacting = true;
312
+ if (this._interactTimer) clearTimeout(this._interactTimer);
313
+ this._interactTimer = setTimeout(() => {
314
+ this._interacting = false;
315
+ this._scheduleRedraw();
316
+ }, 120);
317
+
318
+ // 매 이벤트 동기 _draw() 대신 프레임당 1회로 코얼레싱(_scheduleRedraw 가 PDF 고품질 재렌더도 예약).
319
+ this._scheduleRedraw();
307
320
  };
308
321
  this._onResize = () => {
309
322
  this._setupCanvas();
@@ -340,6 +353,7 @@ export class DrawingOverlay {
340
353
  this._cancelSpringBack();
341
354
  if (this._rafId) cancelAnimationFrame(this._rafId);
342
355
  if (this._qualityTimer) clearTimeout(this._qualityTimer);
356
+ if (this._interactTimer) clearTimeout(this._interactTimer);
343
357
  if (this._abortController) this._abortController.abort();
344
358
 
345
359
  // Remove rubber-band drag listeners
@@ -649,6 +663,7 @@ export class DrawingOverlay {
649
663
  }
650
664
  }
651
665
  this._loading = false;
666
+ this._updateHasPdf();
652
667
 
653
668
  if (this.opts.fitOnLoad) this.fit();
654
669
  this._draw();
@@ -891,6 +906,16 @@ export class DrawingOverlay {
891
906
  Public API: Utility
892
907
  ═══════════════════════════════════════ */
893
908
 
909
+ /** PDF 존재 여부 캐시 갱신 */
910
+ _updateHasPdf() {
911
+ this._hasPdfCached = this._main.isPdf;
912
+ if (!this._hasPdfCached) {
913
+ for (const d of this._drawings.values()) {
914
+ if (d.isPdf && d.visible) { this._hasPdfCached = true; break; }
915
+ }
916
+ }
917
+ }
918
+
894
919
  /** Force redraw. */
895
920
  refresh() {
896
921
  this._setupCanvas();
@@ -1044,6 +1069,7 @@ export class DrawingOverlay {
1044
1069
  }
1045
1070
 
1046
1071
  this._drawings.set(id, state);
1072
+ this._updateHasPdf();
1047
1073
  this._draw();
1048
1074
  this._emit('drawingAdd', id, { x: state.x, y: state.y, width: state.width, height: state.height });
1049
1075
  }
@@ -1059,6 +1085,7 @@ export class DrawingOverlay {
1059
1085
  const state = this._drawings.get(id);
1060
1086
  if (!state) return;
1061
1087
  state.visible = visible;
1088
+ if (state.isPdf) this._updateHasPdf();
1062
1089
  this._draw();
1063
1090
  }
1064
1091
 
@@ -1069,12 +1096,14 @@ export class DrawingOverlay {
1069
1096
 
1070
1097
  removeDrawing(id) {
1071
1098
  this._drawings.delete(id);
1099
+ this._updateHasPdf();
1072
1100
  this._draw();
1073
1101
  this._emit('drawingRemove', id);
1074
1102
  }
1075
1103
 
1076
1104
  clearDrawings() {
1077
1105
  this._drawings.clear();
1106
+ this._updateHasPdf();
1078
1107
  this._draw();
1079
1108
  }
1080
1109
 
@@ -1146,6 +1175,73 @@ export class DrawingOverlay {
1146
1175
  }
1147
1176
  }
1148
1177
 
1178
+ /**
1179
+ * 필터(invert/brightness/contrast/...)를 적용한 소스를 반환.
1180
+ * 매 프레임 ctx.filter 로 큰 도면을 필터링하면 매우 느리므로, 소스(이미지/PDF캔버스)를
1181
+ * 오프스크린 캔버스에 한 번만 구워두고 필터 문자열/소스가 바뀔 때만 재생성한다.
1182
+ * 필터가 없으면(none) 원본 소스를 그대로 반환.
1183
+ */
1184
+ _filtered(holder, source, filterStr) {
1185
+ if (!filterStr || filterStr === 'none') return source;
1186
+ const w = source.naturalWidth || source.width;
1187
+ const h = source.naturalHeight || source.height;
1188
+ if (!w || !h) return source;
1189
+
1190
+ const cache = holder._filteredCache;
1191
+ if (cache && cache.src === source && cache.filter === filterStr && cache.w === w && cache.h === h) {
1192
+ return cache.canvas;
1193
+ }
1194
+
1195
+ const canvas = (cache && cache.canvas) || document.createElement('canvas');
1196
+ if (canvas.width !== w) canvas.width = w;
1197
+ if (canvas.height !== h) canvas.height = h;
1198
+ const cx = canvas.getContext('2d');
1199
+ cx.clearRect(0, 0, w, h);
1200
+ cx.filter = filterStr;
1201
+ cx.imageSmoothingEnabled = true;
1202
+ cx.imageSmoothingQuality = 'high';
1203
+ cx.drawImage(source, 0, 0, w, h);
1204
+ cx.filter = 'none';
1205
+
1206
+ holder._filteredCache = { src: source, filter: filterStr, w, h, canvas };
1207
+ return canvas;
1208
+ }
1209
+
1210
+ /**
1211
+ * 큰 소스(예: 10000×7074 도면)를 매 프레임 통째로 drawImage 하면 pan/zoom 이 끊긴다.
1212
+ * → 최대 변(PROXY_MAX) 으로 축소한 캔버스를 캐시해 인터랙션 중에만 사용. 소스가 작으면 원본 그대로.
1213
+ */
1214
+ _proxy(holder, src) {
1215
+ const PROXY_MAX = this.opts.interactionProxyMax || 2560;
1216
+ const sw = src.naturalWidth || src.width;
1217
+ const sh = src.naturalHeight || src.height;
1218
+ if (!sw || !sh || (sw <= PROXY_MAX && sh <= PROXY_MAX)) return src;
1219
+
1220
+ const scale = PROXY_MAX / Math.max(sw, sh);
1221
+ const pw = Math.max(1, Math.round(sw * scale));
1222
+ const ph = Math.max(1, Math.round(sh * scale));
1223
+
1224
+ const cache = holder._proxyCache;
1225
+ if (cache && cache.src === src && cache.w === pw && cache.h === ph) return cache.canvas;
1226
+
1227
+ const canvas = (cache && cache.canvas) || document.createElement('canvas');
1228
+ canvas.width = pw; canvas.height = ph;
1229
+ const cx = canvas.getContext('2d');
1230
+ cx.clearRect(0, 0, pw, ph);
1231
+ cx.imageSmoothingEnabled = true;
1232
+ cx.imageSmoothingQuality = 'high';
1233
+ cx.drawImage(src, 0, 0, pw, ph);
1234
+
1235
+ holder._proxyCache = { src, w: pw, h: ph, canvas };
1236
+ return canvas;
1237
+ }
1238
+
1239
+ /** _draw 가 사용할 소스: 필터 적용본. 옵션 켜진 경우 인터랙션 중에는 축소 프록시로 대체(멈추면 풀해상도). */
1240
+ _displaySource(holder, source, filterStr) {
1241
+ const full = this._filtered(holder, source, filterStr);
1242
+ return (this._interacting && this.opts.lowQualityOnInteraction) ? this._proxy(holder, full) : full;
1243
+ }
1244
+
1149
1245
  /** Draw an image/canvas with optional rotation around its center. */
1150
1246
  _drawRotated(ctx, source, x, y, w, h, rotation) {
1151
1247
  if (!rotation) {
@@ -1185,9 +1281,10 @@ export class DrawingOverlay {
1185
1281
  const hasAdditional = this._drawings.size > 0;
1186
1282
  if (!hasMain && !hasAdditional) return;
1187
1283
 
1188
- // High-quality image smoothing
1284
+ // Image smoothing — 옵션 켜진 경우에만 pan/zoom 중 'low', 그 외엔 'high'.
1285
+ const lowQ = this._interacting && this.opts.lowQualityOnInteraction;
1189
1286
  ctx.imageSmoothingEnabled = true;
1190
- ctx.imageSmoothingQuality = 'high';
1287
+ ctx.imageSmoothingQuality = lowQ ? 'low' : 'high';
1191
1288
 
1192
1289
  // Apply cytoscape transform
1193
1290
  ctx.setTransform(
@@ -1197,18 +1294,18 @@ export class DrawingOverlay {
1197
1294
  Math.round(pan.y * dpr)
1198
1295
  );
1199
1296
 
1200
- // CSS filters
1201
- ctx.filter = buildFilterString(this.opts);
1297
+ // CSS 필터는 매 프레임 ctx.filter 로 적용하지 않고, 미리 구워둔 캔버스(_filtered)를 사용.
1298
+ const filterStr = buildFilterString(this.opts);
1202
1299
 
1203
1300
  // Draw main source
1204
1301
  if (hasMain) {
1205
1302
  ctx.globalAlpha = this.opts.opacity;
1206
1303
  const mainRot = this.opts.rotation || 0;
1207
1304
  if (this._main.img) {
1208
- this._drawRotated(ctx, this._main.img, 0, 0, this._main.w, this._main.h, mainRot);
1305
+ this._drawRotated(ctx, this._displaySource(this._main, this._main.img, filterStr), 0, 0, this._main.w, this._main.h, mainRot);
1209
1306
  } else if (this._main.pdfCanvas && this._main.pdfClip) {
1210
1307
  const c = this._main.pdfClip;
1211
- this._drawRotated(ctx, this._main.pdfCanvas, c.x, c.y, c.w, c.h, mainRot);
1308
+ this._drawRotated(ctx, this._displaySource(this._main, this._main.pdfCanvas, filterStr), c.x, c.y, c.w, c.h, mainRot);
1212
1309
  }
1213
1310
  }
1214
1311
 
@@ -1218,15 +1315,14 @@ export class DrawingOverlay {
1218
1315
  ctx.globalAlpha = state.opacity * this.opts.opacity;
1219
1316
  const rot = state.rotation || 0;
1220
1317
  if (state.img) {
1221
- this._drawRotated(ctx, state.img, state.x, state.y, state.width, state.height, rot);
1318
+ this._drawRotated(ctx, this._displaySource(state, state.img, filterStr), state.x, state.y, state.width, state.height, rot);
1222
1319
  } else if (state.pdfCanvas && state.pdfClip) {
1223
1320
  const c = state.pdfClip;
1224
- this._drawRotated(ctx, state.pdfCanvas, state.x + c.x, state.y + c.y, c.w, c.h, rot);
1321
+ this._drawRotated(ctx, this._displaySource(state, state.pdfCanvas, filterStr), state.x + c.x, state.y + c.y, c.w, c.h, rot);
1225
1322
  }
1226
1323
  }
1227
1324
 
1228
1325
  // Reset
1229
1326
  ctx.globalAlpha = 1;
1230
- ctx.filter = 'none';
1231
1327
  }
1232
1328
  }
package/src/Minimap.js CHANGED
@@ -183,7 +183,27 @@ export class Minimap {
183
183
 
184
184
  if (main.img && main.img.src) {
185
185
  if (this._lastImgSrc !== main.img.src) {
186
- this._imgDiv.style.backgroundImage = `url(${main.img.src})`;
186
+ // 원본(예: 70MP 도면)을 CSS 배경으로 통째로 디코드하면 메모리·디코드 비용이 큼.
187
+ // → 작은 썸네일 1장으로 축소해 사용(미니맵 화질엔 충분).
188
+ let bg = main.img.src;
189
+ try {
190
+ const THUMB_MAX = 256;
191
+ const iw = main.img.naturalWidth || main.w;
192
+ const ih = main.img.naturalHeight || main.h;
193
+ const s = Math.min(1, THUMB_MAX / Math.max(iw, ih));
194
+ if (s < 1) {
195
+ const tw = Math.max(1, Math.round(iw * s));
196
+ const th = Math.max(1, Math.round(ih * s));
197
+ const c = document.createElement('canvas');
198
+ c.width = tw; c.height = th;
199
+ const cx = c.getContext('2d');
200
+ cx.imageSmoothingEnabled = true;
201
+ cx.imageSmoothingQuality = 'high';
202
+ cx.drawImage(main.img, 0, 0, tw, th);
203
+ bg = c.toDataURL('image/png');
204
+ }
205
+ } catch (_) { /* cross-origin tainted 등 → 원본 src 폴백 */ }
206
+ this._imgDiv.style.backgroundImage = `url(${bg})`;
187
207
  this._lastImgSrc = main.img.src;
188
208
  if (this._blobUrl) { URL.revokeObjectURL(this._blobUrl); this._blobUrl = null; }
189
209
  }