@vkcha/svg-core 0.1.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 ADDED
@@ -0,0 +1,40 @@
1
+ ## Vkcha SVG Core (TypeScript) — base project
2
+
3
+ A lightweight SVG rendering core library in TypeScript for the web: scene graph, viewport culling, zoom/pan, event handling, and a component system with zoom loaders + action views.
4
+
5
+ **Demo Website:** [vkcha.com](https://vkcha.com)
6
+
7
+ ---
8
+
9
+ ### Folder structure
10
+
11
+ - **`src/canvas/`**: `PanZoomCanvas` (wheel zoom + pointer pan)
12
+ - **`src/utils/`**: minimal SVG DOM helper
13
+ - **`demo/`**: demo entry (`demo/index.html`, `demo/main.ts`)
14
+ - **`demo/index.html`**: demo page used by Vite
15
+
16
+ ---
17
+
18
+ ### Run demo (Vite)
19
+
20
+ ```bash
21
+ npm install
22
+ npm run dev
23
+ ```
24
+
25
+ Then open the printed local URL.
26
+
27
+ ---
28
+
29
+ ### Build library (Rollup)
30
+
31
+ ```bash
32
+ npm run build:lib
33
+ ```
34
+
35
+ Outputs:
36
+
37
+ - `dist/vkcha.min.js` (UMD, minified)
38
+ - `dist/index.d.ts` (types)
39
+
40
+ ---
@@ -0,0 +1,234 @@
1
+ type PanZoomState = {
2
+ zoom: number;
3
+ panX: number;
4
+ panY: number;
5
+ };
6
+ type PanZoomListener = (state: Readonly<PanZoomState>) => void;
7
+ type PanZoomWheelMode = "zoom" | "pan";
8
+ type PanZoomOptions = {
9
+ /** Default: "pan" (current behavior). Figma/Mac trackpad feel often uses "pan". */
10
+ wheelMode: PanZoomWheelMode;
11
+ /** If true, zooming via wheel requires Ctrl/Cmd (pinch on macOS typically sets ctrlKey=true). */
12
+ zoomRequiresCtrlKey: boolean;
13
+ /** If true, panning via pointer drag requires holding Space (Figma-like). */
14
+ panRequiresSpaceKey: boolean;
15
+ /** Zoom limits. */
16
+ minZoom: number;
17
+ maxZoom: number;
18
+ /** Zoom speed multiplier for wheel (higher = faster). */
19
+ zoomSpeed: number;
20
+ /** If true, invert wheel direction for zoom. */
21
+ invertZoom: boolean;
22
+ /** If true, invert wheel direction for pan. */
23
+ invertPan: boolean;
24
+ };
25
+ /**
26
+ * Minimal SVG "canvas" with pan/zoom.
27
+ *
28
+ * - Wheel: zoom around cursor
29
+ * - Pointer drag: pan
30
+ *
31
+ * This is intentionally tiny and standalone so the project can restart from a clean base.
32
+ */
33
+ declare class PanZoomCanvas {
34
+ readonly svg: SVGSVGElement;
35
+ readonly world: SVGGElement;
36
+ state: PanZoomState;
37
+ options: PanZoomOptions;
38
+ private listeners;
39
+ private notifyScheduled;
40
+ private dragPointerId;
41
+ private panStart;
42
+ private isSpaceDown;
43
+ private windowKeyDownHandler;
44
+ private windowKeyUpHandler;
45
+ private svgWheelHandler;
46
+ private svgPointerDownHandler;
47
+ private svgPointerMoveHandler;
48
+ private svgPointerUpHandler;
49
+ private svgPointerCancelHandler;
50
+ private svgPointerLeaveHandler;
51
+ constructor(svg: SVGSVGElement, opts?: Partial<PanZoomOptions>);
52
+ setOptions(next: Partial<PanZoomOptions>): void;
53
+ /**
54
+ * Subscribe to pan/zoom state changes (event-driven, no polling).
55
+ *
56
+ * Optimized:
57
+ * - multiple updates within a frame are coalesced into a single notification via rAF
58
+ */
59
+ subscribe(fn: PanZoomListener): () => void;
60
+ setState(next: Partial<PanZoomState>): void;
61
+ reset(): void;
62
+ /**
63
+ * Clean up all event listeners and resources.
64
+ * Call this when the canvas is no longer needed to prevent memory leaks.
65
+ */
66
+ destroy(): void;
67
+ private scheduleNotify;
68
+ private attach;
69
+ private render;
70
+ private svgPoint;
71
+ private screenToWorld;
72
+ }
73
+
74
+ type NodeId = string;
75
+ type NodeOptions = {
76
+ /** Required unique id for the node. Used for selective redraw and removal. */
77
+ id: NodeId;
78
+ /** SVG fragment markup (no outer <svg> wrapper). */
79
+ fragment: string;
80
+ /** World-space position (top-left of the node bounds). Default: (0, 0). */
81
+ x?: number;
82
+ y?: number;
83
+ /**
84
+ * Optional explicit node bounds (in world units). If omitted, the core may derive
85
+ * bounds from the fragment's measured bbox.
86
+ */
87
+ width?: number;
88
+ height?: number;
89
+ /** Optional UI callbacks (hit-tested by the core on the root SVG). */
90
+ onClick?: (node: Node) => void;
91
+ onDoubleClick?: (node: Node) => void;
92
+ onRightClick?: (node: Node) => void;
93
+ };
94
+ /**
95
+ * A scene-graph "node" for the SVG core.
96
+ */
97
+ declare class Node {
98
+ readonly id: NodeId;
99
+ fragment: string;
100
+ x: number;
101
+ y: number;
102
+ width: number | null;
103
+ height: number | null;
104
+ onClick?: NodeOptions["onClick"];
105
+ onDoubleClick?: NodeOptions["onDoubleClick"];
106
+ onRightClick?: NodeOptions["onRightClick"];
107
+ /** Backing element (created lazily by the core). */
108
+ private _el;
109
+ constructor(opts: NodeOptions);
110
+ get el(): SVGGElement;
111
+ }
112
+
113
+ type CullingStats = {
114
+ visible: number;
115
+ hidden: number;
116
+ total: number;
117
+ };
118
+ type CullingOptions = {
119
+ /** Default: true */
120
+ enabled?: boolean;
121
+ /** Default: 30 */
122
+ overscanPx?: number;
123
+ };
124
+ type InitOptions = {
125
+ panZoom?: Partial<PanZoomOptions>;
126
+ culling?: boolean | CullingOptions;
127
+ };
128
+ /**
129
+ * SvgCore entrypoint.
130
+ *
131
+ * Usage:
132
+ * const v = new SvgCore(svgElement)
133
+ */
134
+ declare class SvgCore {
135
+ private canvas;
136
+ private nodesLayer;
137
+ private nodes;
138
+ private nodeIdToIndex;
139
+ private nodeBounds;
140
+ private cullingEnabled;
141
+ private cullingOverscanPx;
142
+ private resizeObserver;
143
+ private unsubPanZoom;
144
+ private unsubSvgEvents;
145
+ private svgClickTimer;
146
+ private suppressNextClick;
147
+ private dragWatch;
148
+ private cullingListeners;
149
+ private lastCullingStats;
150
+ private cullingNotifyScheduled;
151
+ /** SVG root passed to the constructor. */
152
+ get svg(): SVGSVGElement;
153
+ /** World layer (<g>) that you draw into. */
154
+ get world(): SVGGElement;
155
+ /** Current pan/zoom state. */
156
+ get state(): PanZoomState;
157
+ /** Current pan/zoom options (includes minZoom/maxZoom). */
158
+ get panZoomOptions(): Readonly<PanZoomOptions>;
159
+ constructor(svg: SVGSVGElement, opts?: InitOptions);
160
+ /**
161
+ * Set zoom while keeping a chosen screen-space anchor stable.
162
+ * By default anchors at the viewport center.
163
+ */
164
+ setZoom(nextZoom: number, anchor?: {
165
+ x: number;
166
+ y: number;
167
+ }): void;
168
+ /**
169
+ * Convert a pointer position (client px) into canvas/world coordinates,
170
+ * using the current pan/zoom state.
171
+ */
172
+ clientToCanvas(clientX: number, clientY: number): {
173
+ x: number;
174
+ y: number;
175
+ };
176
+ /**
177
+ * Fast hit-test using the culling output: only checks nodes currently attached to `nodesLayer`
178
+ * (i.e. the visible subset after culling).
179
+ *
180
+ * Returns the topmost hit node (based on render order), or null.
181
+ */
182
+ hitTestVisibleNodeAtClient(clientX: number, clientY: number): Node | null;
183
+ zoomBy(factor: number, anchor?: {
184
+ x: number;
185
+ y: number;
186
+ }): void;
187
+ setState(next: Partial<PanZoomState>): void;
188
+ resetView(): void;
189
+ configurePanZoom(opts: Partial<PanZoomOptions>): void;
190
+ setNodes(nodes: Node[]): void;
191
+ /**
192
+ * Redraw the currently assigned nodes.
193
+ *
194
+ * Call this if you mutate node properties in-place (e.g. `node.x = ...` or `node.fragment = ...`).
195
+ *
196
+ * @param ids Optional array of node ids to redraw. If provided, only these nodes will be redrawn.
197
+ * If not provided, all nodes will be redrawn.
198
+ */
199
+ redraw(ids?: string[]): void;
200
+ setCullingEnabled(enabled: boolean): void;
201
+ setCullingOverscanPx(px: number): void;
202
+ /** Subscribe to culling stats updates (event-driven). */
203
+ onCullingStatsChange(fn: (stats: Readonly<CullingStats>) => void): () => void;
204
+ /** Subscribe to pan/zoom updates (event-driven). */
205
+ onPanZoomChange(fn: PanZoomListener): () => void;
206
+ /**
207
+ * Remove nodes from the scene.
208
+ *
209
+ * @param ids Optional array of node ids to remove. If not provided, removes all nodes.
210
+ */
211
+ remove(ids?: string[]): void;
212
+ destroy(): void;
213
+ private renderNodes;
214
+ private applyCulling;
215
+ private setCullingStats;
216
+ private scheduleCullingNotify;
217
+ private rectsIntersect;
218
+ private getWorldViewport;
219
+ }
220
+
221
+ type BBox = {
222
+ x: number;
223
+ y: number;
224
+ width: number;
225
+ height: number;
226
+ };
227
+ type FragmentMetrics = {
228
+ bbox: BBox;
229
+ pad: number;
230
+ };
231
+ declare function measureFragmentMetrics(markup: string): FragmentMetrics | null;
232
+
233
+ export { Node, PanZoomCanvas, SvgCore, measureFragmentMetrics };
234
+ export type { CullingOptions, CullingStats, FragmentMetrics, InitOptions, NodeId, NodeOptions, PanZoomOptions, PanZoomState, PanZoomWheelMode };
@@ -0,0 +1 @@
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).SvgCore={})}(this,function(t){"use strict";const e={wheelMode:"pan",zoomRequiresCtrlKey:!1,panRequiresSpaceKey:!1,minZoom:.2,maxZoom:8,zoomSpeed:1,invertZoom:!1,invertPan:!1};class n{svg;world;state={zoom:1,panX:0,panY:0};options={...e};listeners=new Set;notifyScheduled=!1;dragPointerId=null;panStart=null;isSpaceDown=!1;windowKeyDownHandler=null;windowKeyUpHandler=null;svgWheelHandler=null;svgPointerDownHandler=null;svgPointerMoveHandler=null;svgPointerUpHandler=null;svgPointerCancelHandler=null;svgPointerLeaveHandler=null;constructor(t,e={}){this.svg=t,this.world=function(t,e={}){const n=document.createElementNS("http://www.w3.org/2000/svg",t);for(const[t,s]of Object.entries(e))n.setAttribute(t,s);return n}("g"),this.world.dataset.layer="world",this.svg.replaceChildren(this.world),this.setOptions(e),this.svg.style.touchAction="none",this.svg.style.userSelect="none",this.attach(),this.render()}setOptions(t){this.options={...e,...this.options,...t}}subscribe(t){return this.listeners.add(t),()=>this.listeners.delete(t)}setState(t){const e={...this.state,...t};e.zoom===this.state.zoom&&e.panX===this.state.panX&&e.panY===this.state.panY||(this.state=e,this.render(),this.scheduleNotify())}reset(){this.setState({zoom:1,panX:0,panY:0})}destroy(){if(this.windowKeyDownHandler&&(window.removeEventListener("keydown",this.windowKeyDownHandler),this.windowKeyDownHandler=null),this.windowKeyUpHandler&&(window.removeEventListener("keyup",this.windowKeyUpHandler),this.windowKeyUpHandler=null),this.svgWheelHandler&&(this.svg.removeEventListener("wheel",this.svgWheelHandler),this.svgWheelHandler=null),this.svgPointerDownHandler&&(this.svg.removeEventListener("pointerdown",this.svgPointerDownHandler),this.svgPointerDownHandler=null),this.svgPointerMoveHandler&&(this.svg.removeEventListener("pointermove",this.svgPointerMoveHandler),this.svgPointerMoveHandler=null),this.svgPointerUpHandler&&(this.svg.removeEventListener("pointerup",this.svgPointerUpHandler),this.svgPointerUpHandler=null),this.svgPointerCancelHandler&&(this.svg.removeEventListener("pointercancel",this.svgPointerCancelHandler),this.svgPointerCancelHandler=null),this.svgPointerLeaveHandler&&(this.svg.removeEventListener("pointerleave",this.svgPointerLeaveHandler),this.svgPointerLeaveHandler=null),null!==this.dragPointerId){try{this.svg.releasePointerCapture(this.dragPointerId)}catch{}this.dragPointerId=null}this.panStart=null,this.isSpaceDown=!1,this.listeners.clear()}scheduleNotify(){this.notifyScheduled||(this.notifyScheduled=!0,requestAnimationFrame(()=>{this.notifyScheduled=!1;for(const t of this.listeners)t(this.state)}))}attach(){this.windowKeyDownHandler=t=>{"Space"===t.code&&(this.isSpaceDown=!0)},this.windowKeyUpHandler=t=>{"Space"===t.code&&(this.isSpaceDown=!1)},window.addEventListener("keydown",this.windowKeyDownHandler),window.addEventListener("keyup",this.windowKeyUpHandler),this.svgWheelHandler=t=>{t.preventDefault();const e=this.svgPoint(t.clientX,t.clientY),{wheelMode:n,zoomRequiresCtrlKey:s,invertZoom:i,invertPan:o}=this.options,r=t.ctrlKey||t.metaKey;if("pan"===n&&!r){const e=o?-1:1;return void this.setState({panX:this.state.panX-t.deltaX*e,panY:this.state.panY-t.deltaY*e})}if("zoom"===n&&s&&!r)return;const l=this.screenToWorld(e.x,e.y),a=i?-t.deltaY:t.deltaY,h=Math.exp(.001*-a*this.options.zoomSpeed),d=(c=this.state.zoom*h,u=this.options.minZoom,g=this.options.maxZoom,Math.max(u,Math.min(g,c)));var c,u,g;const v=e.x-l.x*d,p=e.y-l.y*d;this.setState({zoom:d,panX:v,panY:p})},this.svg.addEventListener("wheel",this.svgWheelHandler,{passive:!1}),this.svgPointerDownHandler=t=>{if(null!==this.dragPointerId)return;if(this.options.panRequiresSpaceKey&&!this.isSpaceDown)return;this.dragPointerId=t.pointerId,this.svg.setPointerCapture(t.pointerId);const e=this.svgPoint(t.clientX,t.clientY);this.panStart={panX:this.state.panX,panY:this.state.panY,x:e.x,y:e.y}},this.svg.addEventListener("pointerdown",this.svgPointerDownHandler),this.svgPointerMoveHandler=t=>{if(null===this.dragPointerId)return;if(t.pointerId!==this.dragPointerId)return;if(!this.panStart)return;const e=this.svgPoint(t.clientX,t.clientY),n=e.x-this.panStart.x,s=e.y-this.panStart.y;this.setState({panX:this.panStart.panX+n,panY:this.panStart.panY+s})},this.svg.addEventListener("pointermove",this.svgPointerMoveHandler);const t=t=>{null!==this.dragPointerId&&t.pointerId===this.dragPointerId&&(this.dragPointerId=null,this.panStart=null)};this.svgPointerUpHandler=t,this.svgPointerCancelHandler=t,this.svgPointerLeaveHandler=()=>{this.dragPointerId=null,this.panStart=null},this.svg.addEventListener("pointerup",this.svgPointerUpHandler),this.svg.addEventListener("pointercancel",this.svgPointerCancelHandler),this.svg.addEventListener("pointerleave",this.svgPointerLeaveHandler)}render(){const{zoom:t,panX:e,panY:n}=this.state;this.world.setAttribute("transform",`matrix(${t} 0 0 ${t} ${e} ${n})`)}svgPoint(t,e){const n=this.svg.getBoundingClientRect();return{x:t-n.left,y:e-n.top}}screenToWorld(t,e){const{zoom:n,panX:s,panY:i}=this.state;return{x:(t-s)/n,y:(e-i)/n}}}const s=new Map;let i=null,o=null;function r(t){t.hasAttribute("xmlns")&&t.removeAttribute("xmlns");for(const e of Array.from(t.attributes))e.name.startsWith("xmlns:")&&t.removeAttribute(e.name);for(const e of Array.from(t.children))r(e)}function l(t){const e=t.trim();if(!e)return"";const n=`<svg xmlns="http://www.w3.org/2000/svg">${e}</svg>`;try{const t=(new DOMParser).parseFromString(n,"image/svg+xml"),e=t.documentElement;if(!e||"svg"!==e.nodeName.toLowerCase())return"";t.querySelectorAll("script, foreignObject").forEach(t=>t.remove()),t.querySelectorAll("*").forEach(t=>{for(const e of Array.from(t.attributes))e.name.toLowerCase().startsWith("on")&&t.removeAttribute(e.name),("xmlns"===e.name||e.name.startsWith("xmlns:"))&&t.removeAttribute(e.name)});const s=e.innerHTML;return"string"==typeof s?s.trim():(new XMLSerializer).serializeToString(e).replace(/^<svg[^>]*>|<\/svg>$/g,"").trim()}catch{return""}}function a(t){const e=t.trim();if(!e)return[];const n=`<svg xmlns="http://www.w3.org/2000/svg">${e}</svg>`;try{const t=(new DOMParser).parseFromString(n,"image/svg+xml"),e=t.documentElement;if(!e||"svg"!==e.nodeName.toLowerCase())return[];t.querySelectorAll("script, foreignObject").forEach(t=>t.remove()),t.querySelectorAll("*").forEach(t=>{for(const e of Array.from(t.attributes))e.name.toLowerCase().startsWith("on")&&t.removeAttribute(e.name),("xmlns"===e.name||e.name.startsWith("xmlns:"))&&t.removeAttribute(e.name)});const s=Array.from(e.children).map(t=>document.importNode(t,!0));for(const t of s)r(t);return s}catch{return[]}}function h(t){const e=l(t);if(!e)return null;const n=s.get(e);if(n)return n;const{svg:r,g:h}=function(){if(i&&o)return{svg:i,g:o};const t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("width","0"),t.setAttribute("height","0"),t.style.position="absolute",t.style.left="-10000px",t.style.top="-10000px",t.style.visibility="hidden",t.style.pointerEvents="none";const e=document.createElementNS("http://www.w3.org/2000/svg","g");return t.appendChild(e),i=t,o=e,{svg:t,g:e}}();!function(t){t.isConnected||document.body.appendChild(t)}(r),h.replaceChildren();const d=a(e);for(const t of d)h.appendChild(t.cloneNode(!0));try{const t=h.getBBox();let n=0;h.querySelectorAll("*").forEach(t=>{try{const e=getComputedStyle(t),s=e.stroke;if(!s||"none"===s||"transparent"===s)return;const i=Number.parseFloat(e.strokeWidth??"0");Number.isFinite(i)&&i>n&&(n=i)}catch{}});const i=Math.max(0,n/2),o={bbox:{x:t.x,y:t.y,width:t.width,height:t.height},pad:i};return s.set(e,o),o}catch{return null}finally{!function(t){t.isConnected&&t.remove()}(r)}}t.Node=class{id;fragment;x;y;width;height;onClick;onDoubleClick;onRightClick;_el=null;constructor(t){if(!t||"string"!=typeof t.id||""===t.id)throw new Error("Node requires a non-empty 'id' property");this.id=t.id,this.fragment=t.fragment??"",this.x=Number.isFinite(t?.x)?t?.x:0,this.y=Number.isFinite(t?.y)?t?.y:0;const e=t?.width,n=t?.height;this.width="number"==typeof e&&Number.isFinite(e)&&e>0?e:null,this.height="number"==typeof n&&Number.isFinite(n)&&n>0?n:null,this.onClick=t?.onClick,this.onDoubleClick=t?.onDoubleClick,this.onRightClick=t?.onRightClick}get el(){if(!this._el){const t=document.createElementNS("http://www.w3.org/2000/svg","g");t.dataset.nodeId=this.id,this._el=t}return this._el}},t.PanZoomCanvas=n,t.SvgCore=class{canvas;nodesLayer;nodes=[];nodeIdToIndex=new Map;nodeBounds=null;cullingEnabled=!0;cullingOverscanPx=30;resizeObserver=null;unsubPanZoom=null;unsubSvgEvents=null;svgClickTimer=null;suppressNextClick=!1;dragWatch=null;cullingListeners=new Set;lastCullingStats={visible:0,hidden:0,total:0};cullingNotifyScheduled=!1;get svg(){return this.canvas.svg}get world(){return this.canvas.world}get state(){return this.canvas.state}get panZoomOptions(){return this.canvas.options}constructor(t,e){this.canvas=new n(t,e?.panZoom),this.nodesLayer=document.createElementNS("http://www.w3.org/2000/svg","g"),this.nodesLayer.dataset.layer="nodes",this.world.appendChild(this.nodesLayer),this.world.style.pointerEvents="none";const s=e?.culling;"boolean"==typeof s?this.cullingEnabled=s:s&&("boolean"==typeof s.enabled&&(this.cullingEnabled=s.enabled),"number"==typeof s.overscanPx&&(this.cullingOverscanPx=Math.max(0,s.overscanPx))),this.unsubPanZoom=this.canvas.subscribe(()=>this.applyCulling()),this.resizeObserver=new ResizeObserver(()=>this.applyCulling()),this.resizeObserver.observe(this.svg);const i=()=>{null!==this.svgClickTimer&&(window.clearTimeout(this.svgClickTimer),this.svgClickTimer=null)},o=t=>{0===t.button&&(this.dragWatch={pointerId:t.pointerId,startClientX:t.clientX,startClientY:t.clientY,moved:!1})},r=t=>{const e=this.dragWatch;if(!e)return;if(t.pointerId!==e.pointerId)return;if(1&~t.buttons)return;const n=t.clientX-e.startClientX,s=t.clientY-e.startClientY;!e.moved&&Math.hypot(n,s)>=5&&(e.moved=!0)},l=t=>{const e=this.dragWatch;e&&t.pointerId===e.pointerId&&(this.dragWatch=null,e.moved&&(this.suppressNextClick=!0,i()))},a=t=>{if(this.suppressNextClick)this.suppressNextClick=!1;else{if(null!==this.svgClickTimer){i();const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);return void(e?.onDoubleClick&&e.onDoubleClick(e))}this.svgClickTimer=window.setTimeout(()=>{this.svgClickTimer=null;const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);e?.onClick&&e.onClick(e)},300)}},h=t=>{t.preventDefault(),i();const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);e?.onRightClick&&e.onRightClick(e)};this.svg.addEventListener("click",a),this.svg.addEventListener("contextmenu",h),this.svg.addEventListener("pointerdown",o),this.svg.addEventListener("pointermove",r),this.svg.addEventListener("pointerup",l),this.svg.addEventListener("pointercancel",l),this.unsubSvgEvents=()=>{this.svg.removeEventListener("click",a),this.svg.removeEventListener("contextmenu",h),this.svg.removeEventListener("pointerdown",o),this.svg.removeEventListener("pointermove",r),this.svg.removeEventListener("pointerup",l),this.svg.removeEventListener("pointercancel",l),i()}}setZoom(t,e){const n=this.canvas.options.minZoom,s=this.canvas.options.maxZoom,i=Math.min(s,Math.max(n,t)),o=this.svg.getBoundingClientRect(),r=e?.x??Math.max(1,o.width)/2,l=e?.y??Math.max(1,o.height)/2,a=this.state,h=r-(r-a.panX)/Math.max(1e-9,a.zoom)*i,d=l-(l-a.panY)/Math.max(1e-9,a.zoom)*i;this.setState({zoom:i,panX:h,panY:d})}clientToCanvas(t,e){const n=this.svg.getBoundingClientRect(),s=t-n.left,i=e-n.top,{panX:o,panY:r,zoom:l}=this.state,a=Math.max(1e-9,l);return{x:(s-o)/a,y:(i-r)/a}}hitTestVisibleNodeAtClient(t,e){if(!this.nodeBounds||0===this.nodes.length)return null;const n=this.clientToCanvas(t,e),s=this.nodesLayer.children;for(let t=s.length-1;t>=0;t--){const e=s.item(t);if(!e)continue;const i=e.dataset.nodeId;if(!i)continue;const o=this.nodeIdToIndex.get(i);if(void 0===o)continue;const r=this.nodeBounds[o];if(r&&(n.x>=r.x0&&n.x<=r.x1&&n.y>=r.y0&&n.y<=r.y1))return this.nodes[o]}return null}zoomBy(t,e){const n=Number.isFinite(t)?t:1;n<=0||this.setZoom(this.state.zoom*n,e)}setState(t){this.canvas.setState(t)}resetView(){this.canvas.reset()}configurePanZoom(t){this.canvas.setOptions(t)}setNodes(t){const e=new Set,n=new Set;for(let s=0;s<t.length;s++){const i=t[s].id;e.has(i)?n.add(i):e.add(i)}n.size>0&&console.warn(`Duplicate node ids found: ${Array.from(n).map(t=>`"${t}"`).join(", ")}. Each node should have a unique id.`),this.nodes=t,this.nodeIdToIndex.clear();for(let e=0;e<t.length;e++)this.nodeIdToIndex.set(t[e].id,e);this.redraw()}redraw(t){Array.isArray(t)&&t.length>0?(this.renderNodes(t),this.applyCulling()):(this.renderNodes(),this.applyCulling())}setCullingEnabled(t){this.cullingEnabled=t,this.applyCulling()}setCullingOverscanPx(t){this.cullingOverscanPx=Math.max(0,t),this.applyCulling()}onCullingStatsChange(t){return this.cullingListeners.add(t),t(this.lastCullingStats),()=>this.cullingListeners.delete(t)}onPanZoomChange(t){return this.canvas.subscribe(t)}remove(t){if(!t||0===t.length)return this.nodes=[],this.nodeIdToIndex.clear(),this.nodesLayer.replaceChildren(),this.nodeBounds=null,void this.setCullingStats({visible:0,hidden:0,total:0});const e=new Set;for(const n of t){const t=this.nodeIdToIndex.get(n);void 0!==t&&e.add(t)}if(0===e.size)return;const n=Array.from(e).sort((t,e)=>e-t);for(const t of n){const e=this.nodes[t];e&&(e.el.parentElement&&e.el.remove(),this.nodeIdToIndex.delete(e.id)),this.nodes.splice(t,1)}this.nodeIdToIndex.clear();for(let t=0;t<this.nodes.length;t++)this.nodeIdToIndex.set(this.nodes[t].id,t);if(this.nodeBounds){const t=[];for(let e=0;e<this.nodes.length;e++){const n=this.nodes[e],s=h(n.fragment),i=s?.bbox??{width:240,height:160},o=s?.pad??0,r=n.width??Math.max(1,i.width+2*o),l=n.height??Math.max(1,i.height+2*o);t.push({x0:n.x,y0:n.y,x1:n.x+r,y1:n.y+l})}this.nodeBounds=t}this.applyCulling()}destroy(){this.resizeObserver?.disconnect(),this.resizeObserver=null,this.unsubPanZoom?.(),this.unsubPanZoom=null,this.unsubSvgEvents?.(),this.unsubSvgEvents=null,this.cullingListeners.clear(),this.canvas.destroy()}renderNodes(t){if(t&&t.length>0){for(const e of t){const t=this.nodeIdToIndex.get(e);if(void 0===t)continue;const n=this.nodes[t];if(!n)continue;const s=n.el;s.replaceChildren(),s.setAttribute("transform",`translate(${n.x} ${n.y})`);const i=l(n.fragment);if(i){const e=a(i),o=h(i),r=o?.bbox??{x:0,y:0,width:240,height:160},l=o?.pad??0,d=Math.max(1,r.width+2*l),c=Math.max(1,r.height+2*l),u=-r.x+l,g=-r.y+l,v=document.createElementNS("http://www.w3.org/2000/svg","g");v.setAttribute("transform",`translate(${u} ${g})`);for(const t of e)v.appendChild(t.cloneNode(!0));if(s.appendChild(v),this.nodeBounds){const e=n.width??d,s=n.height??c;this.nodeBounds[t]={x0:n.x,y0:n.y,x1:n.x+e,y1:n.y+s}}}s.parentElement||this.nodesLayer.appendChild(s)}return}if(this.nodesLayer.replaceChildren(),this.nodeBounds=null,0===this.nodes.length)return;const e=new Map;for(const t of this.nodes){const n=l(t.fragment);if(!n)continue;if(e.has(n))continue;const s=a(n),i=h(n),o=i?.bbox??{x:0,y:0,width:240,height:160},r=i?.pad??0,d=Math.max(1,o.width+2*r),c=Math.max(1,o.height+2*r),u=-o.x+r,g=-o.y+r;e.set(n,{children:s,w:d,h:c,offsetX:u,offsetY:g})}const n=this.nodes.length,s=document.createDocumentFragment(),i=new Array(n);for(let t=0;t<n;t++){const n=this.nodes[t],o=n.el;o.replaceChildren(),o.setAttribute("transform",`translate(${n.x} ${n.y})`);const r=l(n.fragment),a=r?e.get(r):null;if(a){const t=document.createElementNS("http://www.w3.org/2000/svg","g");t.setAttribute("transform",`translate(${a.offsetX} ${a.offsetY})`);for(const e of a.children)t.appendChild(e.cloneNode(!0));o.appendChild(t)}const h=n.width??a?.w??240,d=n.height??a?.h??160;i[t]={x0:n.x,y0:n.y,x1:n.x+h,y1:n.y+d},s.appendChild(o)}this.nodesLayer.appendChild(s),this.nodeBounds=i}applyCulling(){if(!this.nodeBounds)return void this.setCullingStats({visible:0,hidden:0,total:this.nodes.length});const t=this.nodes.length;if(!this.cullingEnabled){this.nodesLayer.replaceChildren(...this.nodes.map(t=>t.el));for(const t of this.nodes)t.el.removeAttribute("display");return void this.setCullingStats({visible:t,hidden:0,total:t})}const e=this.getWorldViewport(this.state,this.cullingOverscanPx),n=[];for(let s=0;s<t;s++){const t=this.nodeBounds[s];if(t&&this.rectsIntersect(t,e)){const t=this.nodes[s].el;t.removeAttribute("display"),n.push(t)}}this.nodesLayer.replaceChildren(...n),this.setCullingStats({visible:n.length,hidden:t-n.length,total:t})}setCullingStats(t){const e=this.lastCullingStats;e.visible===t.visible&&e.hidden===t.hidden&&e.total===t.total||(this.lastCullingStats=t,this.scheduleCullingNotify())}scheduleCullingNotify(){this.cullingNotifyScheduled||(this.cullingNotifyScheduled=!0,requestAnimationFrame(()=>{this.cullingNotifyScheduled=!1;for(const t of this.cullingListeners)t(this.lastCullingStats)}))}rectsIntersect(t,e){return!(t.x1<e.x0||t.x0>e.x1||t.y1<e.y0||t.y0>e.y1)}getWorldViewport(t,e){const n=this.svg.getBoundingClientRect(),s=Math.max(1,n.width),i=Math.max(1,n.height),o=Math.max(1e-9,t.zoom),r=Math.max(0,e)/o;return{x0:-t.panX/o-r,y0:-t.panY/o-r,x1:(s-t.panX)/o+r,y1:(i-t.panY)/o+r}}},t.measureFragmentMetrics=h});
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@vkcha/svg-core",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight SVG rendering core library in TypeScript for the web: scene graph, viewport culling, zoom/pan, event handling",
5
+ "keywords": [
6
+ "svg",
7
+ "canvas",
8
+ "pan",
9
+ "zoom",
10
+ "scene-graph",
11
+ "culling",
12
+ "typescript",
13
+ "vkcha"
14
+ ],
15
+ "author": "Vitaliy Frolov",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/vkcha/monorepo.git",
20
+ "directory": "packages/svg-core"
21
+ },
22
+ "bugs": {
23
+ "url": "https://vkcha.com/issues"
24
+ },
25
+ "homepage": "https://vkcha.com",
26
+ "private": false,
27
+ "type": "module",
28
+ "main": "dist/vkcha.min.js",
29
+ "types": "dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/vkcha.min.js",
34
+ "require": "./dist/vkcha.min.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "scripts": {
41
+ "dev": "vite",
42
+ "build:demo": "vite build",
43
+ "build:lib": "rollup -c && node scripts/minify.js",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "preview": "vite preview"
46
+ },
47
+ "devDependencies": {
48
+ "@emotion/react": "^11.14.0",
49
+ "@emotion/styled": "^11.14.1",
50
+ "@mui/icons-material": "^5.16.7",
51
+ "@mui/material": "^5.18.0",
52
+ "@mui/x-tree-view": "^8.24.0",
53
+ "@rollup/plugin-commonjs": "^26.0.1",
54
+ "@rollup/plugin-node-resolve": "^15.3.0",
55
+ "@rollup/plugin-terser": "^0.4.4",
56
+ "@rollup/plugin-typescript": "^11.1.6",
57
+ "@types/react-syntax-highlighter": "^15.5.13",
58
+ "@uiw/react-textarea-code-editor": "^3.0.0",
59
+ "@vitejs/plugin-react": "^4.3.1",
60
+ "react": "^18.3.1",
61
+ "react-dom": "^18.3.1",
62
+ "react-syntax-highlighter": "^15.6.1",
63
+ "rollup": "^4.18.0",
64
+ "rollup-plugin-dts": "^6.3.0",
65
+ "tslib": "^2.8.1",
66
+ "typescript": "^5.5.4",
67
+ "vite": "^5.4.0"
68
+ }
69
+ }