lispgram 0.10.0 → 0.10.2

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.
Files changed (3) hide show
  1. package/README.md +11 -5
  2. package/dist/index.js +250 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -118,10 +118,11 @@ npm install lispgram
118
118
  <script type="module">
119
119
  import lispgram from "lispgram";
120
120
  lispgram.initialize({
121
- startOnLoad: true,
122
- showArrowGlyphs: true,
123
- clickToFocus: true
124
- });
121
+ startOnLoad: true,
122
+ showArrowGlyphs: true,
123
+ clickToFocus: true,
124
+ zoom: "both"
125
+ });
125
126
  </script>
126
127
  ```
127
128
 
@@ -226,6 +227,8 @@ import { render, renderElement, initialize, createDiagramSvg } from "lispgram/la
226
227
 
227
228
  `render(target, source, options?)` renders Lispgram or NCF into a DOM target. `renderElement(sourceElement, options?)` renders one source element. `initialize(options?)` auto-renders matching elements such as `<pre class="lispgram">`. `createDiagramSvg(doc, options?)` renders an already-normalized NCF document into an SVG element.
228
229
 
230
+ `options.zoom` controls built-in shift zoom behavior. The default with no option is `"both"`, which enables Shift-click zoom-to-fit and cursor-centered Shift + wheel zoom. Shift-click fits containers to about 80% of the viewport; Shift-click on ordinary nodes or arrows zooms to that target so its height is about 5% of the view. Use `"shift-wheel"` for cursor-centered Shift + wheel only, `"shift-click-container"` for Shift-click only, or `"none"` to disable zoom. Auto-rendered `<pre>` blocks can set `data-lispgram-zoom="both"`, `data-lispgram-zoom="shift-wheel"`, `data-lispgram-zoom="shift-click-container"`, or `data-lispgram-zoom="none"`.
231
+
229
232
  ### Interaction entry point
230
233
 
231
234
  ```js
@@ -286,6 +289,8 @@ When `clickToFocus` is enabled, clicking a node, compound/container, or arrow fo
286
289
 
287
290
  Click hit-testing uses a transparent interaction layer above the rendered diagram. Ordinary nodes are given priority over arrows, arrows over container interiors, and deeper containers over their ancestors. This keeps large ancestor containers clickable without letting them steal clicks from nested containers such as `BuildSystem`, `ExternalDependencies`, or `GuileBinding`.
288
291
 
292
+ The examples use `zoom: "both"`, which is also the default. It enables Shift-click zoom-to-fit plus Shift-wheel cursor zoom. Containers fit to roughly 80% of the viewport. Ordinary nodes and arrows fit so the clicked target is about 5% of the view height. Pass `zoom: "shift-wheel"` for Shift + wheel only, `zoom: "shift-click-container"` for Shift-click only, or `zoom: "none"` to disable built-in zoom.
293
+
289
294
  ## Demo
290
295
 
291
296
  go to repo root directory
@@ -308,10 +313,11 @@ navigate to examples/basic.html in browser
308
313
  </pre>
309
314
  <script type="module">
310
315
  import lispgram from "https://unpkg.com/lispgram@latest/dist/index.js";
311
- lispgram.initialize({
316
+ lispgram.initialize({
312
317
  startOnLoad: true,
313
318
  showArrowGlyphs: true,
314
319
  clickToFocus: true,
320
+ zoom: "both",
315
321
  arrowGlyphRadius: 5.75
316
322
  });
317
323
  </script>
package/dist/index.js CHANGED
@@ -4772,7 +4772,7 @@ function renderLispgramRoute(svg, route, options) {
4772
4772
  }
4773
4773
 
4774
4774
  function renderLispgramHitLayer(svg, result, options = {}) {
4775
- if (options.clickToFocus === false) return;
4775
+ if (options.clickToFocus === false && normalizeZoomMode(options) === 'none') return;
4776
4776
  const layer = svgEl('g', { class: 'lispgram-hit-layer', 'aria-hidden': 'true' });
4777
4777
 
4778
4778
  // Container hit regions are separate from the visible paint order. They are
@@ -4888,6 +4888,248 @@ function routeTouchesFocus(route, focus) {
4888
4888
  return endpointTouches(route.fromEndpoint, focus.id, focus.type) || endpointTouches(route.toEndpoint, focus.id, focus.type);
4889
4889
  }
4890
4890
 
4891
+ function normalizeZoomMode(options = {}) {
4892
+ const raw = options.zoom ?? options.zoomMode ?? options.interactionZoom;
4893
+ if (raw === false || raw == null && options.noZoom === true) return 'none';
4894
+ if (raw == null || raw === true || raw === '') return 'both';
4895
+ const value = String(raw).trim().toLowerCase().replace(/_/g, '-');
4896
+ if (['none', 'off', 'false', '0', 'no', 'disabled', 'disable'].includes(value)) return 'none';
4897
+ if (['wheel', 'scroll', 'cursor', 'cursor-wheel', 'shift-wheel', 'shift-scroll', 'wheel-at-cursor', 'scroll-at-cursor'].includes(value)) return 'shift-wheel';
4898
+ if (['container', 'click-container', 'shift-click', 'shift-click-container', 'container-fit', 'fit-container', 'zoom-container', 'container-zoom'].includes(value)) return 'shift-click-container';
4899
+ if (['both', 'all', 'shift-both'].includes(value)) return 'both';
4900
+ return 'both';
4901
+ }
4902
+
4903
+ function shiftClickZoomEnabled(options = {}) {
4904
+ const mode = normalizeZoomMode(options);
4905
+ return mode === 'shift-click-container' || mode === 'both';
4906
+ }
4907
+
4908
+ function shiftWheelZoomEnabled(options = {}) {
4909
+ const mode = normalizeZoomMode(options);
4910
+ return mode === 'shift-wheel' || mode === 'both';
4911
+ }
4912
+
4913
+ function parseViewBox(svg) {
4914
+ const raw = svg?.getAttribute?.('viewBox') || '';
4915
+ const nums = raw.trim().split(/[\s,]+/).map(Number).filter(Number.isFinite);
4916
+ if (nums.length >= 4) return { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
4917
+ return null;
4918
+ }
4919
+
4920
+ function writeViewBox(svg, vb) {
4921
+ if (!svg || !vb) return;
4922
+ svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
4923
+ }
4924
+
4925
+ function svgDisplayAspect(svg, fallbackViewBox) {
4926
+ const attrW = Number(svg?.getAttribute?.('width'));
4927
+ const attrH = Number(svg?.getAttribute?.('height'));
4928
+ if (Number.isFinite(attrW) && attrW > 0 && Number.isFinite(attrH) && attrH > 0) return attrW / attrH;
4929
+ const rect = typeof svg?.getBoundingClientRect === 'function' ? svg.getBoundingClientRect() : null;
4930
+ if (rect?.width > 0 && rect?.height > 0) return rect.width / rect.height;
4931
+ return fallbackViewBox?.w && fallbackViewBox?.h ? fallbackViewBox.w / fallbackViewBox.h : 1;
4932
+ }
4933
+
4934
+ function svgPointFromEvent(svg, event, fallbackViewBox) {
4935
+ const vb = parseViewBox(svg) || fallbackViewBox;
4936
+ if (!vb) return null;
4937
+ const rect = typeof svg?.getBoundingClientRect === 'function' ? svg.getBoundingClientRect() : null;
4938
+ if (rect?.width > 0 && rect?.height > 0 && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) {
4939
+ return {
4940
+ x: vb.x + ((event.clientX - rect.left) / rect.width) * vb.w,
4941
+ y: vb.y + ((event.clientY - rect.top) / rect.height) * vb.h
4942
+ };
4943
+ }
4944
+ return { x: vb.x + vb.w / 2, y: vb.y + vb.h / 2 };
4945
+ }
4946
+
4947
+ function zoomViewBoxAt(svg, pivot, factor, originalViewBox, options = {}) {
4948
+ const vb = parseViewBox(svg) || originalViewBox;
4949
+ if (!vb || !pivot || !Number.isFinite(factor) || factor <= 0) return null;
4950
+ const minScale = Number.isFinite(Number(options.zoomMinScale)) ? Number(options.zoomMinScale) : 0.08;
4951
+ const maxScale = Number.isFinite(Number(options.zoomMaxScale)) ? Number(options.zoomMaxScale) : 6;
4952
+ const base = originalViewBox || vb;
4953
+ let nextW = vb.w * factor;
4954
+ let nextH = vb.h * factor;
4955
+ nextW = Math.max(base.w * minScale, Math.min(base.w * maxScale, nextW));
4956
+ nextH = Math.max(base.h * minScale, Math.min(base.h * maxScale, nextH));
4957
+ const actualX = nextW / vb.w;
4958
+ const actualY = nextH / vb.h;
4959
+ const next = {
4960
+ x: pivot.x - (pivot.x - vb.x) * actualX,
4961
+ y: pivot.y - (pivot.y - vb.y) * actualY,
4962
+ w: nextW,
4963
+ h: nextH
4964
+ };
4965
+ writeViewBox(svg, next);
4966
+ return next;
4967
+ }
4968
+
4969
+ function viewBoxForBox(svg, box, originalViewBox, options = {}) {
4970
+ if (!box || !originalViewBox) return null;
4971
+ const ratioRaw = Number(options.zoomFitRatio ?? options.containerZoomFitRatio ?? 0.8);
4972
+ const fitRatio = Number.isFinite(ratioRaw) && ratioRaw > 0 && ratioRaw < 1 ? ratioRaw : 0.8;
4973
+ const aspect = svgDisplayAspect(svg, originalViewBox) || 1;
4974
+ const minW = Number(options.containerZoomMinWidth ?? 160);
4975
+ const minH = Number(options.containerZoomMinHeight ?? 120);
4976
+ let w = Math.max(box.w / fitRatio, (box.h / fitRatio) * aspect, minW);
4977
+ let h = w / aspect;
4978
+ if (h < Math.max(box.h / fitRatio, minH)) {
4979
+ h = Math.max(box.h / fitRatio, minH);
4980
+ w = h * aspect;
4981
+ }
4982
+ const maxW = originalViewBox.w * (Number(options.containerZoomMaxScale ?? 1.05) || 1.05);
4983
+ const maxH = originalViewBox.h * (Number(options.containerZoomMaxScale ?? 1.05) || 1.05);
4984
+ if (w > maxW || h > maxH) {
4985
+ const scale = Math.max(w / maxW, h / maxH);
4986
+ w /= scale;
4987
+ h /= scale;
4988
+ }
4989
+ return { x: box.cx - w / 2, y: box.cy - h / 2, w, h };
4990
+ }
4991
+
4992
+ function finiteBounds(points) {
4993
+ const pts = (points || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
4994
+ if (!pts.length) return null;
4995
+ let left = Infinity;
4996
+ let right = -Infinity;
4997
+ let top = Infinity;
4998
+ let bottom = -Infinity;
4999
+ for (const p of pts) {
5000
+ left = Math.min(left, p.x);
5001
+ right = Math.max(right, p.x);
5002
+ top = Math.min(top, p.y);
5003
+ bottom = Math.max(bottom, p.y);
5004
+ }
5005
+ return { left, right, top, bottom, w: right - left, h: bottom - top, cx: (left + right) / 2, cy: (top + bottom) / 2 };
5006
+ }
5007
+
5008
+ function routeZoomBounds(route, options = {}) {
5009
+ const mid = route?.anchors?.label || route?.anchors?.mid || pointAlongPolyline(route?.points || [], 0.5);
5010
+ const glyphRadius = Number.isFinite(Number(options.arrowGlyphRadius)) ? Number(options.arrowGlyphRadius) : 5;
5011
+ const glyphDiameter = Math.max(10, glyphRadius * 2);
5012
+ const routeBounds = finiteBounds(route?.points || []);
5013
+ if (!mid) return routeBounds;
5014
+ const desired = Math.max(glyphDiameter, Number(options.arrowZoomTargetSize || 0) || 0);
5015
+ const half = desired / 2;
5016
+ return {
5017
+ left: mid.x - half,
5018
+ right: mid.x + half,
5019
+ top: mid.y - half,
5020
+ bottom: mid.y + half,
5021
+ w: desired,
5022
+ h: desired,
5023
+ cx: mid.x,
5024
+ cy: mid.y,
5025
+ routeBounds
5026
+ };
5027
+ }
5028
+
5029
+ function viewBoxForSmallBounds(svg, bounds, originalViewBox, options = {}) {
5030
+ if (!bounds || !originalViewBox) return null;
5031
+ const aspect = svgDisplayAspect(svg, originalViewBox) || 1;
5032
+ const fractionRaw = Number(options.zoomTargetHeightFraction ?? options.smallZoomHeightFraction ?? 0.05);
5033
+ const fraction = Number.isFinite(fractionRaw) && fractionRaw > 0 && fractionRaw < 1 ? fractionRaw : 0.05;
5034
+ const minTargetH = Number.isFinite(Number(options.smallZoomMinTargetHeight)) ? Number(options.smallZoomMinTargetHeight) : 10;
5035
+ const targetH = Math.max(bounds.h || 0, minTargetH);
5036
+ let h = targetH / fraction;
5037
+ let w = h * aspect;
5038
+ const minW = Number(options.smallZoomMinWidth ?? 120);
5039
+ const minH = Number(options.smallZoomMinHeight ?? 90);
5040
+ if (w < minW) { w = minW; h = w / aspect; }
5041
+ if (h < minH) { h = minH; w = h * aspect; }
5042
+ return { x: bounds.cx - w / 2, y: bounds.cy - h / 2, w, h };
5043
+ }
5044
+
5045
+ function viewBoxForZoomTarget(svg, target, result, originalViewBox, options = {}) {
5046
+ if (!target || !originalViewBox) return null;
5047
+ if (target.type === 'node') {
5048
+ const box = result.boxes?.get?.(target.id);
5049
+ if (!box) return null;
5050
+ if (box.kind === 'container') return viewBoxForBox(svg, box, originalViewBox, options);
5051
+ return viewBoxForSmallBounds(svg, box, originalViewBox, options);
5052
+ }
5053
+ if (target.type === 'edge') {
5054
+ const route = result.routes?.get?.(target.id);
5055
+ return viewBoxForSmallBounds(svg, routeZoomBounds(route, options), originalViewBox, options);
5056
+ }
5057
+ return null;
5058
+ }
5059
+
5060
+ function enableLispgramZoom(svg, result, options = {}) {
5061
+ if (!svg || !result) return;
5062
+ const mode = normalizeZoomMode(options);
5063
+ if (mode === 'none') return;
5064
+ const originalViewBox = parseViewBox(svg) || result.viewBox || null;
5065
+ if (!originalViewBox) return;
5066
+ let currentViewBox = { ...originalViewBox };
5067
+ const animationMs = Number.isFinite(Number(options.zoomAnimationMs)) ? Number(options.zoomAnimationMs) : 160;
5068
+
5069
+ function setViewBox(vb) {
5070
+ if (!vb) return null;
5071
+ currentViewBox = { ...vb };
5072
+ if (svg.style && animationMs > 0) svg.style.transition = `viewBox ${animationMs}ms ease`;
5073
+ writeViewBox(svg, currentViewBox);
5074
+ return currentViewBox;
5075
+ }
5076
+
5077
+ function zoomToTarget(target) {
5078
+ return setViewBox(viewBoxForZoomTarget(svg, target, result, originalViewBox, options));
5079
+ }
5080
+
5081
+ function zoomToBox(id) {
5082
+ return zoomToTarget({ type: 'node', id });
5083
+ }
5084
+
5085
+ function zoomToEdge(id) {
5086
+ return zoomToTarget({ type: 'edge', id });
5087
+ }
5088
+
5089
+ function reset() {
5090
+ return setViewBox(originalViewBox);
5091
+ }
5092
+
5093
+ if (shiftWheelZoomEnabled(options) && typeof svg.addEventListener === 'function') {
5094
+ svg.addEventListener('wheel', (event) => {
5095
+ if (!event?.shiftKey) return;
5096
+ if (typeof event.preventDefault === 'function') event.preventDefault();
5097
+ const scale = Number.isFinite(Number(options.zoomWheelScale)) ? Number(options.zoomWheelScale) : 1.08;
5098
+ const delta = Number(event.deltaY || 0);
5099
+ const factor = delta < 0 ? 1 / scale : scale;
5100
+ const pivot = svgPointFromEvent(svg, event, currentViewBox);
5101
+ currentViewBox = zoomViewBoxAt(svg, pivot, factor, originalViewBox, options) || currentViewBox;
5102
+ }, { passive: false });
5103
+ }
5104
+
5105
+ if (shiftClickZoomEnabled(options)) {
5106
+ const hitboxes = [];
5107
+ walkSvgElements(svg, (node) => {
5108
+ if (typeof node.getAttribute !== 'function') return;
5109
+ const cls = node.getAttribute('class');
5110
+ if (cls === 'lispgram-compound-hitbox' || cls === 'lispgram-node-hitbox') {
5111
+ const id = node.getAttribute('data-node-id');
5112
+ if (result.boxes?.has?.(id)) hitboxes.push([node, { type: 'node', id }]);
5113
+ } else if (cls === 'lispgram-edge-hitbox') {
5114
+ const id = node.getAttribute('data-edge-id');
5115
+ if (result.routes?.has?.(id)) hitboxes.push([node, { type: 'edge', id }]);
5116
+ }
5117
+ });
5118
+ for (const [el, target] of hitboxes) {
5119
+ if (typeof el.addEventListener !== 'function') continue;
5120
+ el.addEventListener('click', (event) => {
5121
+ if (!event?.shiftKey) return;
5122
+ if (typeof event.preventDefault === 'function') event.preventDefault();
5123
+ if (typeof event.stopPropagation === 'function') event.stopPropagation();
5124
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
5125
+ zoomToTarget(target);
5126
+ });
5127
+ }
5128
+ }
5129
+
5130
+ svg.__lispgramZoom = { mode, originalViewBox, get viewBox() { return currentViewBox; }, reset, zoomToBox, zoomToEdge, zoomToTarget };
5131
+ }
5132
+
4891
5133
  function enableLispgramClickFocus(svg, result, options = {}) {
4892
5134
  if (!svg || !result || options.clickToFocus === false) return;
4893
5135
  const dimOpacity = Number.isFinite(Number(options.focusDimOpacity)) ? Number(options.focusDimOpacity) : 0.12;
@@ -4960,6 +5202,7 @@ function enableLispgramClickFocus(svg, result, options = {}) {
4960
5202
  if (typeof el.addEventListener !== 'function') continue;
4961
5203
  if (el.style) el.style.cursor = 'pointer';
4962
5204
  el.addEventListener('click', (event) => {
5205
+ if (event?.shiftKey && shiftClickZoomEnabled(options) && result.boxes?.has?.(id)) return;
4963
5206
  stop(event);
4964
5207
  const next = { type: 'node', id };
4965
5208
  applyFocus(sameFocus(currentFocus, next) ? null : next);
@@ -4999,6 +5242,7 @@ function createLispgramConstraintSvg(result, options = {}) {
4999
5242
  for (const route of result.routes.values()) renderLispgramRoute(svg, route, options);
5000
5243
  renderLispgramHitLayer(svg, result, options);
5001
5244
  enableLispgramClickFocus(svg, result, options);
5245
+ enableLispgramZoom(svg, result, options);
5002
5246
  return svg;
5003
5247
  }
5004
5248
 
@@ -5207,6 +5451,8 @@ function elementRenderOptions(sourceElement, options = {}) {
5207
5451
  ...options,
5208
5452
  showArrowGlyphs: parseBooleanOption(data.lispgramArrowGlyphs, options.showArrowGlyphs ?? false),
5209
5453
  clickToFocus: parseBooleanOption(data.lispgramClickFocus, options.clickToFocus ?? true),
5454
+ zoom: data.lispgramZoom || data.lispgramZoomMode || options.zoom || options.zoomMode,
5455
+ zoomMode: data.lispgramZoomMode || data.lispgramZoom || options.zoomMode || options.zoom,
5210
5456
  arrowGlyphRadius: parseNumericOption(data.lispgramArrowGlyphRadius, options.arrowGlyphRadius),
5211
5457
  diagramId: data.lispgramDiagram || data.diagramId || options.diagramId || options.name || options.id || sourceElement?.id || undefined
5212
5458
  };
@@ -5277,6 +5523,8 @@ function initialize(options = {}) {
5277
5523
  showNCF = false,
5278
5524
  showArrowGlyphs = false,
5279
5525
  clickToFocus = true,
5526
+ zoom,
5527
+ zoomMode,
5280
5528
  arrowGlyphRadius = 5.75,
5281
5529
  height,
5282
5530
  width,
@@ -5288,7 +5536,7 @@ function initialize(options = {}) {
5288
5536
  for (const element of elements) {
5289
5537
  if (element.dataset.lispgramMounted === 'true') continue;
5290
5538
  try {
5291
- renderElement(element, { showNCF, showArrowGlyphs, clickToFocus, arrowGlyphRadius, height, width, diagramId });
5539
+ renderElement(element, { showNCF, showArrowGlyphs, clickToFocus, zoom: zoomMode ?? zoom, zoomMode: zoomMode ?? zoom, arrowGlyphRadius, height, width, diagramId });
5292
5540
  element.dataset.lispgramMounted = 'true';
5293
5541
  } catch {
5294
5542
  element.dataset.lispgramMounted = 'error';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lispgram",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "A small public Lispgram surface compiler, constraint-layout SVG renderer, and interaction API.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",