@exellix/diagrams-toolkit 0.2.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.
@@ -0,0 +1,180 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Search, ChevronDown, ChevronUp, MoreHorizontal } from 'lucide-react';
3
+
4
+ /**
5
+ * @typedef {{
6
+ * key: string,
7
+ * label: string,
8
+ * sortable?: boolean,
9
+ * render?: (row: Record<string, unknown>) => React.ReactNode,
10
+ * className?: string,
11
+ * }} DataColumn
12
+ *
13
+ * @typedef {{
14
+ * id: string,
15
+ * label: string,
16
+ * options: Array<{ value: string, label: string }>,
17
+ * }} DataFilter
18
+ */
19
+
20
+ /**
21
+ * @param {{
22
+ * columns: DataColumn[],
23
+ * rows: Array<Record<string, unknown>>,
24
+ * searchPlaceholder?: string,
25
+ * searchKeys?: string[],
26
+ * filters?: DataFilter[],
27
+ * filterValues?: Record<string, string>,
28
+ * onFilterChange?: (key: string, value: string) => void,
29
+ * sortKey?: string,
30
+ * sortDir?: 'asc' | 'desc',
31
+ * onSort?: (key: string) => void,
32
+ * rowActions?: (row: Record<string, unknown>) => React.ReactNode,
33
+ * onRowClick?: (row: Record<string, unknown>) => void,
34
+ * emptyMessage?: string,
35
+ * toolbar?: React.ReactNode,
36
+ * className?: string,
37
+ * }} props
38
+ */
39
+ export function ResourceDataTable({
40
+ columns,
41
+ rows,
42
+ searchPlaceholder = 'Search…',
43
+ searchKeys = [],
44
+ filters = [],
45
+ filterValues = {},
46
+ onFilterChange,
47
+ sortKey,
48
+ sortDir = 'desc',
49
+ onSort,
50
+ rowActions,
51
+ onRowClick,
52
+ emptyMessage = 'No items found.',
53
+ toolbar,
54
+ className = '',
55
+ }) {
56
+ const [search, setSearch] = useState('');
57
+
58
+ const filtered = useMemo(() => {
59
+ let list = [...rows];
60
+ const q = search.trim().toLowerCase();
61
+ if (q) {
62
+ list = list.filter((row) => {
63
+ const keys = searchKeys.length ? searchKeys : columns.map((c) => c.key);
64
+ return keys.some((k) => String(row[k] ?? '').toLowerCase().includes(q));
65
+ });
66
+ }
67
+ for (const f of filters) {
68
+ const val = filterValues[f.id];
69
+ if (val && val !== 'all') {
70
+ list = list.filter((row) => String(row[f.id] ?? '') === val);
71
+ }
72
+ }
73
+ if (sortKey && onSort) {
74
+ list.sort((a, b) => {
75
+ const av = a[sortKey];
76
+ const bv = b[sortKey];
77
+ const cmp =
78
+ typeof av === 'number' && typeof bv === 'number'
79
+ ? av - bv
80
+ : String(av ?? '').localeCompare(String(bv ?? ''));
81
+ return sortDir === 'asc' ? cmp : -cmp;
82
+ });
83
+ }
84
+ return list;
85
+ }, [rows, search, searchKeys, columns, filters, filterValues, sortKey, sortDir, onSort]);
86
+
87
+ return (
88
+ <div className={`flex flex-col min-h-0 ${className}`}>
89
+ <div className="flex flex-wrap items-center gap-3 px-4 py-3 bg-white border-b border-slate-200 shrink-0">
90
+ <div className="relative flex-1 min-w-[12rem] max-w-md">
91
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
92
+ <input
93
+ type="search"
94
+ value={search}
95
+ onChange={(e) => setSearch(e.target.value)}
96
+ placeholder={searchPlaceholder}
97
+ className="w-full pl-9 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
98
+ />
99
+ </div>
100
+ {filters.map((f) => (
101
+ <select
102
+ key={f.id}
103
+ value={filterValues[f.id] ?? 'all'}
104
+ onChange={(e) => onFilterChange?.(f.id, e.target.value)}
105
+ className="text-sm border border-slate-200 rounded-lg px-3 py-2 bg-white"
106
+ >
107
+ <option value="all">All {f.label}</option>
108
+ {f.options.map((o) => (
109
+ <option key={o.value} value={o.value}>
110
+ {o.label}
111
+ </option>
112
+ ))}
113
+ </select>
114
+ ))}
115
+ {toolbar}
116
+ </div>
117
+ <div className="flex-1 min-h-0 overflow-auto">
118
+ <table className="w-full text-sm text-left">
119
+ <thead className="sticky top-0 bg-slate-50 border-b border-slate-200 text-xs uppercase text-slate-500">
120
+ <tr>
121
+ {columns.map((col) => (
122
+ <th key={col.key} className={`px-4 py-3 font-semibold ${col.className ?? ''}`}>
123
+ {col.sortable && onSort ? (
124
+ <button
125
+ type="button"
126
+ onClick={() => onSort(col.key)}
127
+ className="inline-flex items-center gap-1 hover:text-slate-800"
128
+ >
129
+ {col.label}
130
+ {sortKey === col.key ? (
131
+ sortDir === 'asc' ? (
132
+ <ChevronUp size={14} />
133
+ ) : (
134
+ <ChevronDown size={14} />
135
+ )
136
+ ) : null}
137
+ </button>
138
+ ) : (
139
+ col.label
140
+ )}
141
+ </th>
142
+ ))}
143
+ {rowActions ? <th className="px-4 py-3 w-12" /> : null}
144
+ </tr>
145
+ </thead>
146
+ <tbody className="divide-y divide-slate-100 bg-white">
147
+ {filtered.length === 0 ? (
148
+ <tr>
149
+ <td colSpan={columns.length + (rowActions ? 1 : 0)} className="px-4 py-12 text-center text-slate-500">
150
+ {emptyMessage}
151
+ </td>
152
+ </tr>
153
+ ) : (
154
+ filtered.map((row) => (
155
+ <tr
156
+ key={String(row.id ?? row.graphId ?? row.itemId)}
157
+ className={`hover:bg-slate-50 ${onRowClick ? 'cursor-pointer' : ''}`}
158
+ onClick={() => onRowClick?.(row)}
159
+ >
160
+ {columns.map((col) => (
161
+ <td key={col.key} className={`px-4 py-3 ${col.className ?? ''}`}>
162
+ {col.render ? col.render(row) : String(row[col.key] ?? '—')}
163
+ </td>
164
+ ))}
165
+ {rowActions ? (
166
+ <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
167
+ {rowActions(row)}
168
+ </td>
169
+ ) : null}
170
+ </tr>
171
+ ))
172
+ )}
173
+ </tbody>
174
+ </table>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ export { MoreHorizontal };
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+
3
+ /** @type {Record<string, { className: string, label?: string }>} */
4
+ export const DEFAULT_STATUS_PALETTE = {
5
+ draft: { className: 'bg-gray-100 text-gray-600 border-gray-200', label: 'Draft' },
6
+ published: { className: 'bg-green-100 text-green-700 border-green-200', label: 'Published' },
7
+ deleted: { className: 'bg-red-50 text-red-700 border-red-200', label: 'Deleted' },
8
+ planned: { className: 'bg-purple-100 text-purple-700 border-purple-200', label: 'Planned' },
9
+ 'partially-defined': {
10
+ className: 'bg-yellow-100 text-yellow-700 border-yellow-200',
11
+ label: 'Needs Def',
12
+ },
13
+ 'ready-for-config': {
14
+ className: 'bg-blue-100 text-blue-700 border-blue-200',
15
+ label: 'Ready for Config',
16
+ },
17
+ 'implementation-ready': {
18
+ className: 'bg-green-100 text-green-700 border-green-200',
19
+ label: 'Impl Ready',
20
+ },
21
+ };
22
+
23
+ /**
24
+ * @param {{
25
+ * status: string,
26
+ * palette?: Record<string, { className: string, label?: string }>,
27
+ * className?: string,
28
+ * }} props
29
+ */
30
+ export function StatusBadge({ status, palette = DEFAULT_STATUS_PALETTE, className = '' }) {
31
+ const key = typeof status === 'string' ? status.trim() : '';
32
+ const entry = palette[key] || palette.draft || { className: 'bg-gray-100 text-gray-600 border-gray-200' };
33
+ const label = entry.label || key || 'Unknown';
34
+ return (
35
+ <span
36
+ className={`inline-flex px-2 py-0.5 text-xs font-medium rounded-full border ${entry.className} ${className}`}
37
+ >
38
+ {label}
39
+ </span>
40
+ );
41
+ }
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ import { AlertTriangle } from 'lucide-react';
3
+
4
+ const SYNC_LABELS = {
5
+ clean: { text: 'Layers in sync', className: 'bg-emerald-50 text-emerald-800 border-emerald-200' },
6
+ 'concept-ahead': {
7
+ text: 'Concept layer has unpublished changes',
8
+ className: 'bg-amber-50 text-amber-900 border-amber-200',
9
+ },
10
+ 'graph-ahead': {
11
+ text: 'Graph changed — layers may be stale',
12
+ className: 'bg-amber-50 text-amber-900 border-amber-200',
13
+ },
14
+ 'layer-ahead': {
15
+ text: 'Data-flow layer has unpublished changes',
16
+ className: 'bg-amber-50 text-amber-900 border-amber-200',
17
+ },
18
+ conflict: {
19
+ text: 'Layer conflict — resolve before continuing',
20
+ className: 'bg-red-50 text-red-800 border-red-200',
21
+ },
22
+ };
23
+
24
+ /**
25
+ * @param {{
26
+ * syncState: string,
27
+ * onResolve?: () => void,
28
+ * resolveLabel?: string,
29
+ * className?: string,
30
+ * }} props
31
+ */
32
+ export function SyncStateBanner({
33
+ syncState,
34
+ onResolve,
35
+ resolveLabel = 'Resolve',
36
+ className = '',
37
+ }) {
38
+ if (!syncState || syncState === 'clean') return null;
39
+ const entry = SYNC_LABELS[syncState] || SYNC_LABELS.conflict;
40
+
41
+ return (
42
+ <div
43
+ className={`flex items-center gap-3 px-4 py-2 border-b text-sm ${entry.className} ${className}`}
44
+ >
45
+ <AlertTriangle size={16} className="shrink-0" aria-hidden />
46
+ <span className="flex-1">{entry.text}</span>
47
+ {onResolve ? (
48
+ <button
49
+ type="button"
50
+ onClick={onResolve}
51
+ className="shrink-0 px-2.5 py-1 text-xs font-semibold rounded-md border border-current hover:bg-white/40"
52
+ >
53
+ {resolveLabel}
54
+ </button>
55
+ ) : null}
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * @param {{
5
+ * tabs: Array<{ id: string, label: string, disabled?: boolean }>,
6
+ * active: string,
7
+ * onChange: (id: string) => void,
8
+ * className?: string,
9
+ * }} props
10
+ */
11
+ export function ViewTabStrip({ tabs, active, onChange, className = '' }) {
12
+ return (
13
+ <div
14
+ className={`inline-flex items-center gap-0.5 p-0.5 bg-slate-100 border border-slate-200 rounded-lg ${className}`}
15
+ role="tablist"
16
+ >
17
+ {tabs.map((tab) => {
18
+ const isActive = tab.id === active;
19
+ return (
20
+ <button
21
+ key={tab.id}
22
+ type="button"
23
+ role="tab"
24
+ aria-selected={isActive}
25
+ disabled={tab.disabled}
26
+ onClick={() => onChange(tab.id)}
27
+ className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
28
+ isActive
29
+ ? 'bg-white text-indigo-700 shadow-sm border border-slate-200'
30
+ : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
31
+ } disabled:opacity-40 disabled:cursor-not-allowed`}
32
+ >
33
+ {tab.label}
34
+ </button>
35
+ );
36
+ })}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import { Crosshair, ZoomIn, ZoomOut } from 'lucide-react';
3
+
4
+ /**
5
+ * @param {{
6
+ * zoom?: number,
7
+ * minZoom?: number,
8
+ * maxZoom?: number,
9
+ * onZoomChange?: (zoom: number) => void,
10
+ * onZoomIn?: () => void,
11
+ * onZoomOut?: () => void,
12
+ * onFitView?: () => void,
13
+ * showSlider?: boolean,
14
+ * className?: string,
15
+ * }} props
16
+ */
17
+ export function ZoomControls({
18
+ zoom = 100,
19
+ minZoom = 25,
20
+ maxZoom = 200,
21
+ onZoomChange,
22
+ onZoomIn,
23
+ onZoomOut,
24
+ onFitView,
25
+ showSlider = true,
26
+ className = '',
27
+ }) {
28
+ const pct = Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));
29
+
30
+ return (
31
+ <div
32
+ className={`flex items-center gap-2 bg-white border border-slate-200 rounded-lg shadow-md px-2.5 py-2 ${className}`}
33
+ onPointerDown={(e) => e.stopPropagation()}
34
+ >
35
+ {onZoomOut ? (
36
+ <button
37
+ type="button"
38
+ className="p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
39
+ onClick={onZoomOut}
40
+ aria-label="Zoom out"
41
+ >
42
+ <ZoomOut size={16} />
43
+ </button>
44
+ ) : null}
45
+ {showSlider && onZoomChange ? (
46
+ <input
47
+ type="range"
48
+ min={minZoom}
49
+ max={maxZoom}
50
+ step={5}
51
+ value={pct}
52
+ onChange={(e) => onZoomChange(Number(e.target.value))}
53
+ className="flex-1 min-w-[6rem] h-1.5 cursor-pointer accent-indigo-600"
54
+ aria-label="Canvas zoom"
55
+ />
56
+ ) : null}
57
+ {onZoomIn ? (
58
+ <button
59
+ type="button"
60
+ className="p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
61
+ onClick={onZoomIn}
62
+ aria-label="Zoom in"
63
+ >
64
+ <ZoomIn size={16} />
65
+ </button>
66
+ ) : null}
67
+ {onFitView ? (
68
+ <button
69
+ type="button"
70
+ className="p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
71
+ onClick={onFitView}
72
+ title="Fit in view"
73
+ aria-label="Fit in view"
74
+ >
75
+ <Crosshair size={16} />
76
+ </button>
77
+ ) : null}
78
+ </div>
79
+ );
80
+ }
package/src/index.js ADDED
@@ -0,0 +1,39 @@
1
+ export { ViewTabStrip } from './ViewTabStrip.jsx';
2
+ export { JsonDocumentPanel } from './JsonDocumentPanel.jsx';
3
+ export { FlowCanvas } from './FlowCanvas.jsx';
4
+ export { GraphCanvas } from './GraphCanvas.jsx';
5
+ export { GraphCanvasZoomControl } from './GraphCanvasZoomControl.jsx';
6
+ export { DefaultGraphEdge } from './DefaultGraphEdge.jsx';
7
+ export { ConceptSummaryStrip } from './ConceptSummaryStrip.jsx';
8
+ export { ResourceDataTable } from './ResourceDataTable.jsx';
9
+ export { StatusBadge, DEFAULT_STATUS_PALETTE } from './StatusBadge.jsx';
10
+ export { InspectorShell } from './InspectorShell.jsx';
11
+ export { ZoomControls } from './ZoomControls.jsx';
12
+ export { EmptyState } from './EmptyState.jsx';
13
+ export { ErrorState } from './ErrorState.jsx';
14
+ export { SyncStateBanner } from './SyncStateBanner.jsx';
15
+ export {
16
+ clampGraphCanvasScale,
17
+ graphCanvasScaleToPercent,
18
+ graphCanvasPercentToScale,
19
+ GRAPH_CANVAS_MIN_SCALE,
20
+ GRAPH_CANVAS_MAX_SCALE,
21
+ GRAPH_CANVAS_MIN_PERCENT,
22
+ GRAPH_CANVAS_MAX_PERCENT,
23
+ } from './lib/graphCanvasZoom.js';
24
+ export {
25
+ layoutGraphNodesFromTopology,
26
+ calculateAutoLayout,
27
+ withEdgeCounts,
28
+ DEFAULT_NODE_CARD_WIDTH,
29
+ DEFAULT_CANVAS_LEFT_INSET,
30
+ } from './lib/graphCanvasLayout.js';
31
+ export { computeEdgeBezierPath } from './lib/computeEdgeBezierPath.js';
32
+ export {
33
+ computeGraphViewportResizePan,
34
+ canvasNodesForViewportBounds,
35
+ graphContentScreenBounds,
36
+ GRAPH_VIEWPORT_RESIZE_EDGE_PAD,
37
+ GRAPH_NODE_CARD_WIDTH,
38
+ GRAPH_NODE_CARD_APPROX_HEIGHT,
39
+ } from './lib/graphViewportResizePan.js';
@@ -0,0 +1,32 @@
1
+ import { DEFAULT_NODE_CARD_WIDTH } from './graphCanvasLayout.js';
2
+
3
+ /**
4
+ * Cubic-bezier path from source node right-center to target node left-center.
5
+ * @param {{ x: number, y: number }} sourceNode
6
+ * @param {{ x: number, y: number }} targetNode
7
+ * @param {{ nodeWidth?: number, anchorY?: number, minControlOffset?: number }} [opts]
8
+ * @returns {{ path: string, startX: number, startY: number, endX: number, endY: number, midX: number, midY: number }}
9
+ */
10
+ export function computeEdgeBezierPath(sourceNode, targetNode, opts = {}) {
11
+ const nodeWidth = opts.nodeWidth ?? DEFAULT_NODE_CARD_WIDTH;
12
+ const anchorY = opts.anchorY ?? 60;
13
+ const minControlOffset = opts.minControlOffset ?? 50;
14
+
15
+ const startX = sourceNode.x + nodeWidth;
16
+ const startY = sourceNode.y + anchorY;
17
+ const endX = targetNode.x;
18
+ const endY = targetNode.y + anchorY;
19
+
20
+ const controlPointOffset = Math.max(Math.abs(endX - startX) / 2, minControlOffset);
21
+ const path = `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`;
22
+
23
+ return {
24
+ path,
25
+ startX,
26
+ startY,
27
+ endX,
28
+ endY,
29
+ midX: (startX + endX) / 2,
30
+ midY: (startY + endY) / 2,
31
+ };
32
+ }
@@ -0,0 +1,99 @@
1
+ /** Default layout constants matching graphs-studio node cards. */
2
+ export const DEFAULT_NODE_CARD_WIDTH = 320;
3
+ export const GRAPH_NODE_CARD_APPROX_HEIGHT = 240;
4
+ export const DEFAULT_VIRTUAL_ANCHOR_GAP = 100;
5
+ export const DEFAULT_CANVAS_LEFT_INSET =
6
+ DEFAULT_NODE_CARD_WIDTH + DEFAULT_VIRTUAL_ANCHOR_GAP + 60;
7
+
8
+ /**
9
+ * @param {object[]} nodes
10
+ * @param {object[]} edges
11
+ * @param {{ columnGap?: number, rowGap?: number, leftInset?: number, baseX?: number, baseY?: number }} [opts]
12
+ */
13
+ export function calculateAutoLayout(nodes, edges, opts = {}) {
14
+ const columnGap = opts.columnGap ?? 380;
15
+ const rowGap = opts.rowGap ?? 340;
16
+ const leftInset = opts.leftInset ?? DEFAULT_CANVAS_LEFT_INSET;
17
+ const baseX = opts.baseX ?? 100;
18
+ const baseY = opts.baseY ?? 100;
19
+
20
+ const levels = {};
21
+ const inDegree = {};
22
+ const outDegree = {};
23
+
24
+ nodes.forEach((n) => {
25
+ inDegree[n.id] = 0;
26
+ outDegree[n.id] = [];
27
+ });
28
+
29
+ edges.forEach((e) => {
30
+ if (inDegree[e.to] !== undefined) inDegree[e.to]++;
31
+ if (outDegree[e.from]) {
32
+ if (!outDegree[e.from].includes(e.to)) outDegree[e.from].push(e.to);
33
+ } else {
34
+ outDegree[e.from] = [e.to];
35
+ }
36
+ });
37
+
38
+ let queue = nodes.filter((n) => inDegree[n.id] === 0).map((n) => n.id);
39
+ queue.forEach((id) => {
40
+ levels[id] = 0;
41
+ });
42
+
43
+ while (queue.length > 0) {
44
+ const u = queue.shift();
45
+ (outDegree[u] || []).forEach((v) => {
46
+ if (levels[v] === undefined || levels[v] < levels[u] + 1) {
47
+ levels[v] = levels[u] + 1;
48
+ queue.push(v);
49
+ }
50
+ });
51
+ }
52
+
53
+ nodes.forEach((n) => {
54
+ if (levels[n.id] === undefined) levels[n.id] = 0;
55
+ });
56
+
57
+ const levelCounts = {};
58
+ return nodes.map((n) => {
59
+ const l = levels[n.id];
60
+ if (levelCounts[l] === undefined) levelCounts[l] = 0;
61
+ const x = l * columnGap + baseX + leftInset;
62
+ const y = levelCounts[l] * rowGap + baseY;
63
+ levelCounts[l]++;
64
+ return { ...n, x, y };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * @param {object[]} nodes
70
+ * @param {object[]} edges
71
+ */
72
+ export function withEdgeCounts(nodes, edges) {
73
+ const counts = (Array.isArray(edges) ? edges : []).reduce(
74
+ (acc, e) => {
75
+ if (!acc.in[e.to]) acc.in[e.to] = 0;
76
+ if (!acc.out[e.from]) acc.out[e.from] = 0;
77
+ acc.in[e.to]++;
78
+ acc.out[e.from]++;
79
+ return acc;
80
+ },
81
+ { in: {}, out: {} },
82
+ );
83
+ return (Array.isArray(nodes) ? nodes : []).map((n) => ({
84
+ ...n,
85
+ _inCount: counts.in[n.id] || 0,
86
+ _outCount: counts.out[n.id] || 0,
87
+ }));
88
+ }
89
+
90
+ /**
91
+ * Auto-layout canvas nodes + edge in/out counts from canonical node/edge lists.
92
+ * @param {object[]} nList
93
+ * @param {object[]} eList
94
+ * @param {{ columnGap?: number, rowGap?: number, leftInset?: number }} [layoutOpts]
95
+ */
96
+ export function layoutGraphNodesFromTopology(nList, eList, layoutOpts) {
97
+ const layoutedNodes = calculateAutoLayout(nList, eList, layoutOpts);
98
+ return withEdgeCounts(layoutedNodes, eList);
99
+ }
@@ -0,0 +1,33 @@
1
+ /** Canvas zoom limits (match graph-editor wheel + toolbar). */
2
+ export const GRAPH_CANVAS_MIN_SCALE = 0.1;
3
+ export const GRAPH_CANVAS_MAX_SCALE = 3;
4
+
5
+ export const GRAPH_CANVAS_MIN_PERCENT = Math.round(GRAPH_CANVAS_MIN_SCALE * 100);
6
+ export const GRAPH_CANVAS_MAX_PERCENT = Math.round(GRAPH_CANVAS_MAX_SCALE * 100);
7
+
8
+ /**
9
+ * @param {number} scale
10
+ * @returns {number}
11
+ */
12
+ export function clampGraphCanvasScale(scale) {
13
+ if (!Number.isFinite(scale)) return 1;
14
+ return Math.min(GRAPH_CANVAS_MAX_SCALE, Math.max(GRAPH_CANVAS_MIN_SCALE, scale));
15
+ }
16
+
17
+ /**
18
+ * @param {number} scale
19
+ * @returns {number} Integer percent for UI (10–300).
20
+ */
21
+ export function graphCanvasScaleToPercent(scale) {
22
+ return Math.round(clampGraphCanvasScale(scale) * 100);
23
+ }
24
+
25
+ /**
26
+ * @param {number} percent
27
+ * @returns {number}
28
+ */
29
+ export function graphCanvasPercentToScale(percent) {
30
+ const n = Number(percent);
31
+ if (!Number.isFinite(n)) return 1;
32
+ return clampGraphCanvasScale(n / 100);
33
+ }