@diwauhris/ui 1.7.42 → 1.7.43
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/lib/assets/ui.css +12 -0
- package/lib/index.cjs +312 -117
- package/lib/index.mjs +313 -118
- package/lib/types/ui-library/gallery/org-chart/OrgChart.d.ts +27 -11
- package/package.json +1 -1
package/lib/assets/ui.css
CHANGED
|
@@ -514,6 +514,11 @@
|
|
|
514
514
|
margin-right: auto;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
.my-0\.5 {
|
|
518
|
+
margin-top: 0.125rem;
|
|
519
|
+
margin-bottom: 0.125rem;
|
|
520
|
+
}
|
|
521
|
+
|
|
517
522
|
.-mr-1 {
|
|
518
523
|
margin-right: -0.25rem;
|
|
519
524
|
}
|
|
@@ -1207,6 +1212,10 @@
|
|
|
1207
1212
|
min-width: 240px;
|
|
1208
1213
|
}
|
|
1209
1214
|
|
|
1215
|
+
.min-w-\[3rem\] {
|
|
1216
|
+
min-width: 3rem;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1210
1219
|
.min-w-\[400px\] {
|
|
1211
1220
|
min-width: 400px;
|
|
1212
1221
|
}
|
|
@@ -5136,6 +5145,9 @@ body {
|
|
|
5136
5145
|
--tw-shadow-colored: 0 0 #0000;
|
|
5137
5146
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
|
5138
5147
|
}
|
|
5148
|
+
.disabled\:hover\:bg-transparent:hover:disabled {
|
|
5149
|
+
background-color: transparent;
|
|
5150
|
+
}
|
|
5139
5151
|
.disabled\:active\:scale-100:active:disabled {
|
|
5140
5152
|
--tw-scale-x: 1;
|
|
5141
5153
|
--tw-scale-y: 1;
|
package/lib/index.cjs
CHANGED
|
@@ -6206,25 +6206,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6206
6206
|
* - 250 × 84 px rounded rectangles (rx=14)
|
|
6207
6207
|
* - Root (depth 0): dark navy fill (#0f172a), white text
|
|
6208
6208
|
* - Children: white fill (#fff), slate border (#e2e8f0)
|
|
6209
|
+
* - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
|
|
6209
6210
|
* - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
|
|
6210
|
-
* - Name: 13px, font-weight 800, centered
|
|
6211
|
+
* - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
|
|
6211
6212
|
* - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
|
|
6212
|
-
* - Selected: indigo border overlay ring
|
|
6213
|
+
* - Hover: indigo-300 border; Selected: indigo border + overlay ring
|
|
6213
6214
|
* Connectors:
|
|
6214
6215
|
* - Stepped orthogonal path: M→V→H→V
|
|
6215
6216
|
* - Stroke: #cbd5e1, stroke-width 1.5
|
|
6216
6217
|
* Canvas:
|
|
6217
|
-
* -
|
|
6218
|
-
*
|
|
6219
|
-
*
|
|
6218
|
+
* - Fits the tree to the viewport until the user zooms or pans; after that
|
|
6219
|
+
* the user's view is kept across re-renders, data refreshes and resizes
|
|
6220
|
+
* (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
|
|
6221
|
+
* `fitKey` hands control back to auto-fit.
|
|
6222
|
+
* - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
|
|
6223
|
+
* - Pan: mouse/touch drag (a drag never counts as a node click)
|
|
6220
6224
|
* - Layout: nodeSize [280, 185] — horizontal × vertical spacing
|
|
6221
6225
|
* Controls:
|
|
6222
|
-
* -
|
|
6226
|
+
* - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
|
|
6223
6227
|
*
|
|
6224
6228
|
* Accessibility:
|
|
6225
|
-
* - SVG has role="img"
|
|
6226
|
-
* -
|
|
6227
|
-
* - Keyboard:
|
|
6229
|
+
* - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
|
|
6230
|
+
* - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
|
|
6231
|
+
* - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
|
|
6232
|
+
* - Respects prefers-reduced-motion for button zoom transitions
|
|
6228
6233
|
* - Note: full ARIA tree semantics are not possible in an SVG diagram;
|
|
6229
6234
|
* use OrgUnitTree for a fully keyboard-accessible list-based alternative
|
|
6230
6235
|
*
|
|
@@ -6236,6 +6241,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6236
6241
|
* aria-label="Company organization chart"
|
|
6237
6242
|
* />
|
|
6238
6243
|
*/
|
|
6244
|
+
var SYNTHETIC_ROOT_ID = "__synthetic_root__";
|
|
6245
|
+
var NODE_W = 250;
|
|
6246
|
+
var NODE_H = 84;
|
|
6247
|
+
var TEXT_MAX_W = 218;
|
|
6248
|
+
var MIN_SCALE = .05;
|
|
6249
|
+
var MAX_SCALE = 1.8;
|
|
6250
|
+
var BUTTON_ZOOM_STEP = 1.2;
|
|
6239
6251
|
function buildD3Tree(nodes) {
|
|
6240
6252
|
function convert(node, depth) {
|
|
6241
6253
|
return {
|
|
@@ -6244,161 +6256,344 @@ function buildD3Tree(nodes) {
|
|
|
6244
6256
|
description: node.description ?? "",
|
|
6245
6257
|
isRoot: depth === 0,
|
|
6246
6258
|
isSyntheticRoot: false,
|
|
6259
|
+
isMuted: node.tone === "muted",
|
|
6247
6260
|
originalId: node.id,
|
|
6248
6261
|
children: node.children?.map((c) => convert(c, depth + 1))
|
|
6249
6262
|
};
|
|
6250
6263
|
}
|
|
6251
6264
|
if (nodes.length === 1) return convert(nodes[0], 0);
|
|
6252
6265
|
return {
|
|
6253
|
-
id:
|
|
6266
|
+
id: SYNTHETIC_ROOT_ID,
|
|
6254
6267
|
name: "",
|
|
6255
6268
|
description: "",
|
|
6256
6269
|
isRoot: true,
|
|
6257
6270
|
isSyntheticRoot: true,
|
|
6258
|
-
|
|
6271
|
+
isMuted: false,
|
|
6272
|
+
originalId: SYNTHETIC_ROOT_ID,
|
|
6259
6273
|
children: nodes.map((n) => convert(n, 0))
|
|
6260
6274
|
};
|
|
6261
6275
|
}
|
|
6262
|
-
|
|
6276
|
+
/** Writes `text` into an SVG text element, truncating with an ellipsis to fit `maxWidth`. */
|
|
6277
|
+
function fitText(el, text, maxWidth, fontSize) {
|
|
6278
|
+
el.textContent = text;
|
|
6279
|
+
if (!text) return;
|
|
6280
|
+
const measured = typeof el.getComputedTextLength === "function" ? el.getComputedTextLength() : 0;
|
|
6281
|
+
if (!measured) {
|
|
6282
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / (fontSize * .6)));
|
|
6283
|
+
if (text.length > maxChars) el.textContent = `${text.slice(0, maxChars - 1).trimEnd()}…`;
|
|
6284
|
+
return;
|
|
6285
|
+
}
|
|
6286
|
+
if (measured <= maxWidth) return;
|
|
6287
|
+
let lo = 0;
|
|
6288
|
+
let hi = text.length - 1;
|
|
6289
|
+
while (lo < hi) {
|
|
6290
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
6291
|
+
el.textContent = `${text.slice(0, mid).trimEnd()}…`;
|
|
6292
|
+
if (el.getComputedTextLength() <= maxWidth) lo = mid;
|
|
6293
|
+
else hi = mid - 1;
|
|
6294
|
+
}
|
|
6295
|
+
el.textContent = `${text.slice(0, lo).trimEnd()}…`;
|
|
6296
|
+
}
|
|
6297
|
+
var prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6298
|
+
function OrgChart({ nodes, selectedId, onSelect, fitKey, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
|
|
6263
6299
|
const svgRef = (0, react.useRef)(null);
|
|
6264
6300
|
const zoomRef = (0, react.useRef)(null);
|
|
6265
|
-
const initialTransformRef = (0, react.useRef)(null);
|
|
6266
6301
|
const treeBoundsRef = (0, react.useRef)(null);
|
|
6267
|
-
const
|
|
6302
|
+
const autoFitRef = (0, react.useRef)(true);
|
|
6303
|
+
const programmaticRef = (0, react.useRef)(false);
|
|
6304
|
+
const pendingFitRef = (0, react.useRef)(false);
|
|
6305
|
+
const lastSizeRef = (0, react.useRef)(null);
|
|
6306
|
+
const onSelectRef = (0, react.useRef)(onSelect);
|
|
6307
|
+
const selectedIdRef = (0, react.useRef)(selectedId);
|
|
6308
|
+
const scaleFrameRef = (0, react.useRef)(0);
|
|
6309
|
+
const [scale, setScale] = (0, react.useState)(1);
|
|
6310
|
+
const uid = `oc-${(0, react.useId)().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
6311
|
+
const interactive = Boolean(onSelect);
|
|
6312
|
+
const isEmpty = nodes.length === 0;
|
|
6313
|
+
const getSize = () => {
|
|
6314
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
6315
|
+
return {
|
|
6316
|
+
width: rect?.width ?? 0,
|
|
6317
|
+
height: rect?.height ?? 0
|
|
6318
|
+
};
|
|
6319
|
+
};
|
|
6320
|
+
/** Applies a transform without handing control away from auto-fit. */
|
|
6321
|
+
const applyTransform = (t) => {
|
|
6322
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6323
|
+
const svg = d3.select(svgRef.current);
|
|
6324
|
+
svg.interrupt();
|
|
6325
|
+
programmaticRef.current = true;
|
|
6326
|
+
try {
|
|
6327
|
+
svg.call(zoomRef.current.transform, t);
|
|
6328
|
+
} finally {
|
|
6329
|
+
programmaticRef.current = false;
|
|
6330
|
+
}
|
|
6331
|
+
};
|
|
6268
6332
|
const fitToView = () => {
|
|
6269
6333
|
if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
|
|
6270
|
-
const { width: W, height: H } =
|
|
6271
|
-
if (W === 0 || H === 0)
|
|
6334
|
+
const { width: W, height: H } = getSize();
|
|
6335
|
+
if (W === 0 || H === 0) {
|
|
6336
|
+
pendingFitRef.current = true;
|
|
6337
|
+
return;
|
|
6338
|
+
}
|
|
6339
|
+
pendingFitRef.current = false;
|
|
6272
6340
|
const { minX, maxX, minY, maxY } = treeBoundsRef.current;
|
|
6273
|
-
const contentLeft = minX -
|
|
6274
|
-
const contentRight = maxX +
|
|
6275
|
-
const contentTop = minY -
|
|
6276
|
-
const contentBottom = maxY +
|
|
6341
|
+
const contentLeft = minX - NODE_W / 2;
|
|
6342
|
+
const contentRight = maxX + NODE_W / 2;
|
|
6343
|
+
const contentTop = minY - NODE_H / 2;
|
|
6344
|
+
const contentBottom = maxY + NODE_H / 2;
|
|
6277
6345
|
const contentWidth = contentRight - contentLeft;
|
|
6278
6346
|
const contentHeight = contentBottom - contentTop;
|
|
6279
6347
|
const scaleX = (W - 80) / Math.max(1, contentWidth);
|
|
6280
6348
|
const scaleY = (H - 80) / Math.max(1, contentHeight);
|
|
6281
|
-
const
|
|
6282
|
-
const
|
|
6283
|
-
const
|
|
6284
|
-
|
|
6285
|
-
const ty = H / 2 - contentCenterY * scale;
|
|
6286
|
-
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
|
|
6287
|
-
initialTransformRef.current = t;
|
|
6288
|
-
d3.select(svgRef.current).call(zoomRef.current.transform, t);
|
|
6349
|
+
const k = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scaleX, scaleY));
|
|
6350
|
+
const tx = W / 2 - (contentLeft + contentRight) / 2 * k;
|
|
6351
|
+
const ty = H / 2 - (contentTop + contentBottom) / 2 * k;
|
|
6352
|
+
applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
|
|
6289
6353
|
};
|
|
6290
|
-
(
|
|
6291
|
-
|
|
6354
|
+
/** Fit, and go back to keeping the chart fitted (Fit button, new fitKey). */
|
|
6355
|
+
const resetToFit = () => {
|
|
6356
|
+
autoFitRef.current = true;
|
|
6357
|
+
fitToView();
|
|
6358
|
+
};
|
|
6359
|
+
const zoomBy = (factor) => {
|
|
6360
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6361
|
+
autoFitRef.current = false;
|
|
6292
6362
|
const svg = d3.select(svgRef.current);
|
|
6293
|
-
svg.
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6363
|
+
if (prefersReducedMotion()) svg.interrupt().call(zoomRef.current.scaleBy, factor);
|
|
6364
|
+
else svg.transition().duration(250).call(zoomRef.current.scaleBy, factor);
|
|
6365
|
+
};
|
|
6366
|
+
(0, react.useEffect)(() => {
|
|
6367
|
+
onSelectRef.current = onSelect;
|
|
6368
|
+
}, [onSelect]);
|
|
6369
|
+
(0, react.useEffect)(() => {
|
|
6370
|
+
const svgEl = svgRef.current;
|
|
6371
|
+
if (!svgEl) return;
|
|
6372
|
+
const svg = d3.select(svgEl);
|
|
6373
|
+
const viewport = svg.append("g").attr("class", "oc-viewport");
|
|
6374
|
+
viewport.append("g").attr("class", "oc-links");
|
|
6375
|
+
viewport.append("g").attr("class", "oc-nodes");
|
|
6376
|
+
const zoom = d3.zoom().scaleExtent([MIN_SCALE, MAX_SCALE]).extent(() => {
|
|
6377
|
+
const { width, height } = getSize();
|
|
6378
|
+
return [[0, 0], [Math.max(1, width), Math.max(1, height)]];
|
|
6379
|
+
}).clickDistance(5).filter((event) => {
|
|
6380
|
+
if (event.type === "dblclick") return false;
|
|
6381
|
+
const e = event;
|
|
6382
|
+
return (!e.ctrlKey || event.type === "wheel") && !e.button;
|
|
6383
|
+
}).on("zoom", (event) => {
|
|
6384
|
+
viewport.attr("transform", event.transform.toString());
|
|
6385
|
+
if (!programmaticRef.current) autoFitRef.current = false;
|
|
6386
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6387
|
+
scaleFrameRef.current = requestAnimationFrame(() => setScale(event.transform.k));
|
|
6388
|
+
});
|
|
6389
|
+
zoomRef.current = zoom;
|
|
6390
|
+
svg.call(zoom);
|
|
6391
|
+
const ro = new ResizeObserver(() => {
|
|
6392
|
+
const size = getSize();
|
|
6393
|
+
if (size.width === 0 || size.height === 0) return;
|
|
6394
|
+
const last = lastSizeRef.current;
|
|
6395
|
+
lastSizeRef.current = size;
|
|
6396
|
+
if (autoFitRef.current || pendingFitRef.current || !last) {
|
|
6397
|
+
fitToView();
|
|
6398
|
+
return;
|
|
6399
|
+
}
|
|
6400
|
+
const dx = (size.width - last.width) / 2;
|
|
6401
|
+
const dy = (size.height - last.height) / 2;
|
|
6402
|
+
if (dx === 0 && dy === 0) return;
|
|
6403
|
+
const t = d3.zoomTransform(svgEl);
|
|
6404
|
+
applyTransform(d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
|
|
6405
|
+
});
|
|
6406
|
+
ro.observe(svgEl);
|
|
6407
|
+
return () => {
|
|
6408
|
+
ro.disconnect();
|
|
6409
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6410
|
+
svg.interrupt().on(".zoom", null);
|
|
6411
|
+
svg.selectAll("*").remove();
|
|
6412
|
+
zoomRef.current = null;
|
|
6413
|
+
};
|
|
6414
|
+
}, []);
|
|
6415
|
+
(0, react.useEffect)(() => {
|
|
6416
|
+
const svgEl = svgRef.current;
|
|
6417
|
+
if (!svgEl) return;
|
|
6418
|
+
const svg = d3.select(svgEl);
|
|
6419
|
+
const linkLayer = svg.select("g.oc-links");
|
|
6420
|
+
const nodeLayer = svg.select("g.oc-nodes");
|
|
6421
|
+
if (nodes.length === 0) {
|
|
6422
|
+
linkLayer.selectAll("*").remove();
|
|
6423
|
+
nodeLayer.selectAll("*").remove();
|
|
6424
|
+
treeBoundsRef.current = null;
|
|
6425
|
+
return;
|
|
6426
|
+
}
|
|
6427
|
+
const root = d3.hierarchy(buildD3Tree(nodes));
|
|
6428
|
+
const pointRoot = d3.tree().nodeSize([280, 185])(root);
|
|
6429
|
+
const descendants = pointRoot.descendants();
|
|
6298
6430
|
treeBoundsRef.current = {
|
|
6299
6431
|
minX: d3.min(descendants, (d) => d.x) ?? 0,
|
|
6300
6432
|
maxX: d3.max(descendants, (d) => d.x) ?? 0,
|
|
6301
6433
|
minY: d3.min(descendants, (d) => d.y) ?? 0,
|
|
6302
6434
|
maxY: d3.max(descendants, (d) => d.y) ?? 0
|
|
6303
6435
|
};
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
svg.call(zoom);
|
|
6308
|
-
g.selectAll("path").data(root.links().filter((l) => !l.source.data.isSyntheticRoot)).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6309
|
-
const sx = link.source.x ?? 0;
|
|
6310
|
-
const sy = link.source.y ?? 0;
|
|
6311
|
-
const tx = link.target.x ?? 0;
|
|
6312
|
-
const ty = link.target.y ?? 0;
|
|
6313
|
-
return `M${sx},${sy}V${(sy + ty) / 2}H${tx}V${ty}`;
|
|
6436
|
+
linkLayer.selectAll("path").data(pointRoot.links().filter((l) => !l.source.data.isSyntheticRoot), (l) => `${l.source.data.originalId}>${l.target.data.originalId}`).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6437
|
+
const mid = (link.source.y + link.target.y) / 2;
|
|
6438
|
+
return `M${link.source.x},${link.source.y}V${mid}H${link.target.x}V${link.target.y}`;
|
|
6314
6439
|
});
|
|
6315
|
-
const node =
|
|
6316
|
-
|
|
6317
|
-
|
|
6440
|
+
const node = nodeLayer.selectAll("g.oc-node").data(descendants.filter((d) => !d.data.isSyntheticRoot), (d) => d.data.originalId).join((enter) => {
|
|
6441
|
+
const g = enter.append("g").attr("class", "oc-node");
|
|
6442
|
+
g.append("title");
|
|
6443
|
+
g.append("rect").attr("class", "oc-card").attr("x", -125).attr("y", -42).attr("width", NODE_W).attr("height", NODE_H).attr("rx", 14).attr("filter", "drop-shadow(0 6px 12px rgba(15,23,42,0.08))");
|
|
6444
|
+
g.append("rect").attr("class", "oc-ring").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none");
|
|
6445
|
+
g.append("text").attr("class", "oc-name").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800);
|
|
6446
|
+
g.append("text").attr("class", "oc-desc").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600);
|
|
6447
|
+
return g;
|
|
6448
|
+
}).attr("transform", (d) => `translate(${d.x},${d.y})`).classed("is-root", (d) => d.data.isRoot).classed("is-muted", (d) => d.data.isMuted).classed("is-selected", (d) => d.data.originalId === selectedIdRef.current).attr("aria-label", (d) => d.data.description ? `${d.data.name}, ${d.data.description}` : d.data.name).on("click", (_e, d) => onSelectRef.current?.(d.data.originalId)).on("keydown", (event, d) => {
|
|
6449
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
6450
|
+
event.preventDefault();
|
|
6451
|
+
onSelectRef.current?.(d.data.originalId);
|
|
6452
|
+
}).on("focus", (_e, d) => {
|
|
6453
|
+
if (!zoomRef.current) return;
|
|
6454
|
+
const { width: W, height: H } = getSize();
|
|
6455
|
+
const [sx, sy] = d3.zoomTransform(svgEl).apply([d.x, d.y]);
|
|
6456
|
+
const margin = 24;
|
|
6457
|
+
if (sx >= margin && sx <= W - margin && sy >= margin && sy <= H - margin) return;
|
|
6458
|
+
autoFitRef.current = false;
|
|
6459
|
+
svg.interrupt().call(zoomRef.current.translateTo, d.x, d.y);
|
|
6318
6460
|
});
|
|
6319
|
-
node.
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
})
|
|
6323
|
-
node.
|
|
6324
|
-
|
|
6325
|
-
if (selectedId) node.filter((d) => d.data.originalId === selectedId).append("rect").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none").attr("stroke", "#6366f1").attr("stroke-width", 2.5).attr("opacity", .6);
|
|
6326
|
-
requestAnimationFrame(() => {
|
|
6327
|
-
fitToView();
|
|
6461
|
+
node.select("title").text((d) => d.data.description ? `${d.data.name}\n${d.data.description}` : d.data.name);
|
|
6462
|
+
node.select("text.oc-name").each(function(d) {
|
|
6463
|
+
fitText(this, d.data.name, TEXT_MAX_W, 13);
|
|
6464
|
+
});
|
|
6465
|
+
node.select("text.oc-desc").each(function(d) {
|
|
6466
|
+
fitText(this, d.data.description, TEXT_MAX_W, 11);
|
|
6328
6467
|
});
|
|
6468
|
+
if (autoFitRef.current || pendingFitRef.current) fitToView();
|
|
6469
|
+
}, [nodes]);
|
|
6470
|
+
(0, react.useEffect)(() => {
|
|
6471
|
+
if (!svgRef.current) return;
|
|
6472
|
+
d3.select(svgRef.current).selectAll("g.oc-node").attr("tabindex", interactive ? 0 : null).attr("role", interactive ? "button" : null);
|
|
6473
|
+
}, [interactive, nodes]);
|
|
6474
|
+
(0, react.useEffect)(() => {
|
|
6475
|
+
selectedIdRef.current = selectedId;
|
|
6476
|
+
if (!svgRef.current) return;
|
|
6477
|
+
d3.select(svgRef.current).selectAll("g.oc-node").classed("is-selected", (d) => d.data.originalId === selectedId).attr("aria-pressed", interactive ? (d) => String(d.data.originalId === selectedId) : null);
|
|
6329
6478
|
}, [
|
|
6330
|
-
nodes,
|
|
6331
6479
|
selectedId,
|
|
6332
|
-
|
|
6480
|
+
interactive,
|
|
6481
|
+
nodes
|
|
6333
6482
|
]);
|
|
6483
|
+
const fitKeyRef = (0, react.useRef)(fitKey);
|
|
6334
6484
|
(0, react.useEffect)(() => {
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
};
|
|
6347
|
-
const resetZoom = () => {
|
|
6348
|
-
fitToView();
|
|
6485
|
+
if (fitKeyRef.current === fitKey) return;
|
|
6486
|
+
fitKeyRef.current = fitKey;
|
|
6487
|
+
resetToFit();
|
|
6488
|
+
}, [fitKey]);
|
|
6489
|
+
const handleKeyDown = (event) => {
|
|
6490
|
+
if (event.ctrlKey || event.metaKey || event.altKey || isEmpty) return;
|
|
6491
|
+
if (event.key === "+" || event.key === "=") zoomBy(BUTTON_ZOOM_STEP);
|
|
6492
|
+
else if (event.key === "-" || event.key === "_") zoomBy(1 / BUTTON_ZOOM_STEP);
|
|
6493
|
+
else if (event.key === "0") resetToFit();
|
|
6494
|
+
else return;
|
|
6495
|
+
event.preventDefault();
|
|
6349
6496
|
};
|
|
6497
|
+
const atMax = scale >= 1.7990000000000002;
|
|
6498
|
+
const atMin = scale <= .051000000000000004;
|
|
6350
6499
|
const controlBtnClass = [
|
|
6351
6500
|
"appearance-none border-0 bg-none font-[inherit]",
|
|
6352
6501
|
"flex items-center justify-center rounded-lg p-2",
|
|
6353
|
-
"text-slate-
|
|
6354
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30"
|
|
6502
|
+
"text-slate-500 transition hover:bg-slate-100 hover:text-slate-700",
|
|
6503
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30",
|
|
6504
|
+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
|
6355
6505
|
].join(" ");
|
|
6356
6506
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
6357
|
-
className: `relative w-full overflow-hidden bg-slate-50/50 ${className}`,
|
|
6507
|
+
className: `relative w-full overflow-hidden bg-slate-50/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-blue/30 ${className}`,
|
|
6358
6508
|
style: { height },
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6509
|
+
tabIndex: isEmpty ? void 0 : 0,
|
|
6510
|
+
"aria-keyshortcuts": isEmpty ? void 0 : "+ - 0",
|
|
6511
|
+
onKeyDown: handleKeyDown,
|
|
6512
|
+
children: [
|
|
6513
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `
|
|
6514
|
+
#${uid} .oc-card { fill: #fff; stroke: #e2e8f0; stroke-width: 1; transition: stroke 120ms ease; }
|
|
6515
|
+
#${uid} .oc-name { fill: #0f172a; }
|
|
6516
|
+
#${uid} .oc-desc { fill: #64748b; }
|
|
6517
|
+
#${uid} .oc-node.is-root .oc-card { fill: #0f172a; stroke: #0f172a; }
|
|
6518
|
+
#${uid} .oc-node.is-root .oc-name { fill: #fff; }
|
|
6519
|
+
#${uid} .oc-node.is-root .oc-desc { fill: #cbd5e1; }
|
|
6520
|
+
#${uid} .oc-node.is-muted .oc-card { fill: #f8fafc; stroke: #94a3b8; stroke-dasharray: 5 4; }
|
|
6521
|
+
#${uid} .oc-node.is-muted .oc-name { fill: #475569; }
|
|
6522
|
+
#${uid} .oc-node.is-muted .oc-desc { fill: #94a3b8; }
|
|
6523
|
+
#${uid} .oc-node.is-root.is-muted .oc-card { fill: #334155; stroke: #94a3b8; }
|
|
6524
|
+
#${uid} .oc-node.is-root.is-muted .oc-name { fill: #e2e8f0; }
|
|
6525
|
+
#${uid} .oc-node.is-root.is-muted .oc-desc { fill: #cbd5e1; }
|
|
6526
|
+
#${uid} .oc-ring { stroke: #6366f1; stroke-width: 2.5; opacity: 0; pointer-events: none; }
|
|
6527
|
+
#${uid}.is-interactive .oc-node { cursor: pointer; }
|
|
6528
|
+
#${uid}.is-interactive .oc-node:hover .oc-card { stroke: #a5b4fc; stroke-width: 1.5; }
|
|
6529
|
+
#${uid} .oc-node.is-selected .oc-card,
|
|
6530
|
+
#${uid}.is-interactive .oc-node.is-selected:hover .oc-card { stroke: #6366f1; stroke-width: 2.5; }
|
|
6531
|
+
#${uid} .oc-node.is-selected .oc-ring { opacity: 0.6; }
|
|
6532
|
+
#${uid} .oc-node:focus { outline: none; }
|
|
6533
|
+
#${uid} .oc-node:focus-visible .oc-ring { opacity: 1; stroke: #2563eb; }
|
|
6534
|
+
` }),
|
|
6535
|
+
!isEmpty && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
6536
|
+
className: "absolute right-4 top-4 z-10 flex flex-col items-center gap-1 rounded-lg border border-slate-100 bg-white p-1.5 shadow-md",
|
|
6537
|
+
children: [
|
|
6538
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
6539
|
+
type: "button",
|
|
6540
|
+
className: controlBtnClass,
|
|
6541
|
+
onClick: () => zoomBy(BUTTON_ZOOM_STEP),
|
|
6542
|
+
disabled: atMax,
|
|
6543
|
+
"aria-label": "Zoom in",
|
|
6544
|
+
title: "Zoom in (+)",
|
|
6545
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomIn, {
|
|
6546
|
+
size: 16,
|
|
6547
|
+
"aria-hidden": "true"
|
|
6548
|
+
})
|
|
6549
|
+
}),
|
|
6550
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
6551
|
+
className: "min-w-[3rem] select-none text-center text-[11px] font-semibold tabular-nums text-slate-500",
|
|
6552
|
+
children: [Math.round(scale * 100), "%"]
|
|
6553
|
+
}),
|
|
6554
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
6555
|
+
type: "button",
|
|
6556
|
+
className: controlBtnClass,
|
|
6557
|
+
onClick: () => zoomBy(1 / BUTTON_ZOOM_STEP),
|
|
6558
|
+
disabled: atMin,
|
|
6559
|
+
"aria-label": "Zoom out",
|
|
6560
|
+
title: "Zoom out (-)",
|
|
6561
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ZoomOut, {
|
|
6562
|
+
size: 16,
|
|
6563
|
+
"aria-hidden": "true"
|
|
6564
|
+
})
|
|
6565
|
+
}),
|
|
6566
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
6567
|
+
className: "my-0.5 h-px w-6 bg-slate-100",
|
|
6389
6568
|
"aria-hidden": "true"
|
|
6569
|
+
}),
|
|
6570
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
6571
|
+
type: "button",
|
|
6572
|
+
className: controlBtnClass,
|
|
6573
|
+
onClick: resetToFit,
|
|
6574
|
+
"aria-label": "Fit to screen",
|
|
6575
|
+
title: "Fit to screen (0)",
|
|
6576
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Scan, {
|
|
6577
|
+
size: 16,
|
|
6578
|
+
"aria-hidden": "true"
|
|
6579
|
+
})
|
|
6390
6580
|
})
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6401
|
-
|
|
6581
|
+
]
|
|
6582
|
+
}),
|
|
6583
|
+
isEmpty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
6584
|
+
className: "absolute inset-0 flex items-center justify-center p-6 text-center text-sm font-semibold text-slate-500",
|
|
6585
|
+
children: "No units to display"
|
|
6586
|
+
}),
|
|
6587
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
6588
|
+
ref: svgRef,
|
|
6589
|
+
id: uid,
|
|
6590
|
+
width: "100%",
|
|
6591
|
+
height: "100%",
|
|
6592
|
+
className: `block touch-none ${interactive ? "is-interactive" : ""}`,
|
|
6593
|
+
role: interactive ? "group" : "img",
|
|
6594
|
+
"aria-label": ariaLabel
|
|
6595
|
+
})
|
|
6596
|
+
]
|
|
6402
6597
|
});
|
|
6403
6598
|
}
|
|
6404
6599
|
//#endregion
|
package/lib/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import * as React$1 from "react";
|
|
|
4
4
|
import React, { cloneElement, createContext, createElement, isValidElement, use, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
|
-
import { AlertCircle, AlertTriangle, Archive, ArchiveRestore, ArrowDown, Ban, CalendarDays, Check, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clock, Command, File, Home, Info, Minus,
|
|
7
|
+
import { AlertCircle, AlertTriangle, Archive, ArchiveRestore, ArrowDown, Ban, CalendarDays, Check, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clock, Command, File, Home, Info, Minus, Scan, Search as Search$1, ShieldCheck, TrendingDown, TrendingUp, UploadCloud, X, ZoomIn, ZoomOut } from "lucide-react";
|
|
8
8
|
import Calendar$1 from "react-calendar";
|
|
9
9
|
import { format, isValid, parse } from "date-fns";
|
|
10
10
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
@@ -6135,25 +6135,30 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6135
6135
|
* - 250 × 84 px rounded rectangles (rx=14)
|
|
6136
6136
|
* - Root (depth 0): dark navy fill (#0f172a), white text
|
|
6137
6137
|
* - Children: white fill (#fff), slate border (#e2e8f0)
|
|
6138
|
+
* - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
|
|
6138
6139
|
* - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
|
|
6139
|
-
* - Name: 13px, font-weight 800, centered
|
|
6140
|
+
* - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
|
|
6140
6141
|
* - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
|
|
6141
|
-
* - Selected: indigo border overlay ring
|
|
6142
|
+
* - Hover: indigo-300 border; Selected: indigo border + overlay ring
|
|
6142
6143
|
* Connectors:
|
|
6143
6144
|
* - Stepped orthogonal path: M→V→H→V
|
|
6144
6145
|
* - Stroke: #cbd5e1, stroke-width 1.5
|
|
6145
6146
|
* Canvas:
|
|
6146
|
-
* -
|
|
6147
|
-
*
|
|
6148
|
-
*
|
|
6147
|
+
* - Fits the tree to the viewport until the user zooms or pans; after that
|
|
6148
|
+
* the user's view is kept across re-renders, data refreshes and resizes
|
|
6149
|
+
* (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
|
|
6150
|
+
* `fitKey` hands control back to auto-fit.
|
|
6151
|
+
* - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
|
|
6152
|
+
* - Pan: mouse/touch drag (a drag never counts as a node click)
|
|
6149
6153
|
* - Layout: nodeSize [280, 185] — horizontal × vertical spacing
|
|
6150
6154
|
* Controls:
|
|
6151
|
-
* -
|
|
6155
|
+
* - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
|
|
6152
6156
|
*
|
|
6153
6157
|
* Accessibility:
|
|
6154
|
-
* - SVG has role="img"
|
|
6155
|
-
* -
|
|
6156
|
-
* - Keyboard:
|
|
6158
|
+
* - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
|
|
6159
|
+
* - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
|
|
6160
|
+
* - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
|
|
6161
|
+
* - Respects prefers-reduced-motion for button zoom transitions
|
|
6157
6162
|
* - Note: full ARIA tree semantics are not possible in an SVG diagram;
|
|
6158
6163
|
* use OrgUnitTree for a fully keyboard-accessible list-based alternative
|
|
6159
6164
|
*
|
|
@@ -6165,6 +6170,13 @@ function Heatmap({ data, scale = "blue", showLegend = true, showMonthLabels = tr
|
|
|
6165
6170
|
* aria-label="Company organization chart"
|
|
6166
6171
|
* />
|
|
6167
6172
|
*/
|
|
6173
|
+
var SYNTHETIC_ROOT_ID = "__synthetic_root__";
|
|
6174
|
+
var NODE_W = 250;
|
|
6175
|
+
var NODE_H = 84;
|
|
6176
|
+
var TEXT_MAX_W = 218;
|
|
6177
|
+
var MIN_SCALE = .05;
|
|
6178
|
+
var MAX_SCALE = 1.8;
|
|
6179
|
+
var BUTTON_ZOOM_STEP = 1.2;
|
|
6168
6180
|
function buildD3Tree(nodes) {
|
|
6169
6181
|
function convert(node, depth) {
|
|
6170
6182
|
return {
|
|
@@ -6173,161 +6185,344 @@ function buildD3Tree(nodes) {
|
|
|
6173
6185
|
description: node.description ?? "",
|
|
6174
6186
|
isRoot: depth === 0,
|
|
6175
6187
|
isSyntheticRoot: false,
|
|
6188
|
+
isMuted: node.tone === "muted",
|
|
6176
6189
|
originalId: node.id,
|
|
6177
6190
|
children: node.children?.map((c) => convert(c, depth + 1))
|
|
6178
6191
|
};
|
|
6179
6192
|
}
|
|
6180
6193
|
if (nodes.length === 1) return convert(nodes[0], 0);
|
|
6181
6194
|
return {
|
|
6182
|
-
id:
|
|
6195
|
+
id: SYNTHETIC_ROOT_ID,
|
|
6183
6196
|
name: "",
|
|
6184
6197
|
description: "",
|
|
6185
6198
|
isRoot: true,
|
|
6186
6199
|
isSyntheticRoot: true,
|
|
6187
|
-
|
|
6200
|
+
isMuted: false,
|
|
6201
|
+
originalId: SYNTHETIC_ROOT_ID,
|
|
6188
6202
|
children: nodes.map((n) => convert(n, 0))
|
|
6189
6203
|
};
|
|
6190
6204
|
}
|
|
6191
|
-
|
|
6205
|
+
/** Writes `text` into an SVG text element, truncating with an ellipsis to fit `maxWidth`. */
|
|
6206
|
+
function fitText(el, text, maxWidth, fontSize) {
|
|
6207
|
+
el.textContent = text;
|
|
6208
|
+
if (!text) return;
|
|
6209
|
+
const measured = typeof el.getComputedTextLength === "function" ? el.getComputedTextLength() : 0;
|
|
6210
|
+
if (!measured) {
|
|
6211
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / (fontSize * .6)));
|
|
6212
|
+
if (text.length > maxChars) el.textContent = `${text.slice(0, maxChars - 1).trimEnd()}…`;
|
|
6213
|
+
return;
|
|
6214
|
+
}
|
|
6215
|
+
if (measured <= maxWidth) return;
|
|
6216
|
+
let lo = 0;
|
|
6217
|
+
let hi = text.length - 1;
|
|
6218
|
+
while (lo < hi) {
|
|
6219
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
6220
|
+
el.textContent = `${text.slice(0, mid).trimEnd()}…`;
|
|
6221
|
+
if (el.getComputedTextLength() <= maxWidth) lo = mid;
|
|
6222
|
+
else hi = mid - 1;
|
|
6223
|
+
}
|
|
6224
|
+
el.textContent = `${text.slice(0, lo).trimEnd()}…`;
|
|
6225
|
+
}
|
|
6226
|
+
var prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6227
|
+
function OrgChart({ nodes, selectedId, onSelect, fitKey, height = "75vh", "aria-label": ariaLabel = "Organization chart", className = "" }) {
|
|
6192
6228
|
const svgRef = useRef(null);
|
|
6193
6229
|
const zoomRef = useRef(null);
|
|
6194
|
-
const initialTransformRef = useRef(null);
|
|
6195
6230
|
const treeBoundsRef = useRef(null);
|
|
6196
|
-
const
|
|
6231
|
+
const autoFitRef = useRef(true);
|
|
6232
|
+
const programmaticRef = useRef(false);
|
|
6233
|
+
const pendingFitRef = useRef(false);
|
|
6234
|
+
const lastSizeRef = useRef(null);
|
|
6235
|
+
const onSelectRef = useRef(onSelect);
|
|
6236
|
+
const selectedIdRef = useRef(selectedId);
|
|
6237
|
+
const scaleFrameRef = useRef(0);
|
|
6238
|
+
const [scale, setScale] = useState(1);
|
|
6239
|
+
const uid = `oc-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
6240
|
+
const interactive = Boolean(onSelect);
|
|
6241
|
+
const isEmpty = nodes.length === 0;
|
|
6242
|
+
const getSize = () => {
|
|
6243
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
6244
|
+
return {
|
|
6245
|
+
width: rect?.width ?? 0,
|
|
6246
|
+
height: rect?.height ?? 0
|
|
6247
|
+
};
|
|
6248
|
+
};
|
|
6249
|
+
/** Applies a transform without handing control away from auto-fit. */
|
|
6250
|
+
const applyTransform = (t) => {
|
|
6251
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6252
|
+
const svg = d3.select(svgRef.current);
|
|
6253
|
+
svg.interrupt();
|
|
6254
|
+
programmaticRef.current = true;
|
|
6255
|
+
try {
|
|
6256
|
+
svg.call(zoomRef.current.transform, t);
|
|
6257
|
+
} finally {
|
|
6258
|
+
programmaticRef.current = false;
|
|
6259
|
+
}
|
|
6260
|
+
};
|
|
6197
6261
|
const fitToView = () => {
|
|
6198
6262
|
if (!svgRef.current || !zoomRef.current || !treeBoundsRef.current) return;
|
|
6199
|
-
const { width: W, height: H } =
|
|
6200
|
-
if (W === 0 || H === 0)
|
|
6263
|
+
const { width: W, height: H } = getSize();
|
|
6264
|
+
if (W === 0 || H === 0) {
|
|
6265
|
+
pendingFitRef.current = true;
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
pendingFitRef.current = false;
|
|
6201
6269
|
const { minX, maxX, minY, maxY } = treeBoundsRef.current;
|
|
6202
|
-
const contentLeft = minX -
|
|
6203
|
-
const contentRight = maxX +
|
|
6204
|
-
const contentTop = minY -
|
|
6205
|
-
const contentBottom = maxY +
|
|
6270
|
+
const contentLeft = minX - NODE_W / 2;
|
|
6271
|
+
const contentRight = maxX + NODE_W / 2;
|
|
6272
|
+
const contentTop = minY - NODE_H / 2;
|
|
6273
|
+
const contentBottom = maxY + NODE_H / 2;
|
|
6206
6274
|
const contentWidth = contentRight - contentLeft;
|
|
6207
6275
|
const contentHeight = contentBottom - contentTop;
|
|
6208
6276
|
const scaleX = (W - 80) / Math.max(1, contentWidth);
|
|
6209
6277
|
const scaleY = (H - 80) / Math.max(1, contentHeight);
|
|
6210
|
-
const
|
|
6211
|
-
const
|
|
6212
|
-
const
|
|
6213
|
-
|
|
6214
|
-
const ty = H / 2 - contentCenterY * scale;
|
|
6215
|
-
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
|
|
6216
|
-
initialTransformRef.current = t;
|
|
6217
|
-
d3.select(svgRef.current).call(zoomRef.current.transform, t);
|
|
6278
|
+
const k = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scaleX, scaleY));
|
|
6279
|
+
const tx = W / 2 - (contentLeft + contentRight) / 2 * k;
|
|
6280
|
+
const ty = H / 2 - (contentTop + contentBottom) / 2 * k;
|
|
6281
|
+
applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
|
|
6218
6282
|
};
|
|
6219
|
-
|
|
6220
|
-
|
|
6283
|
+
/** Fit, and go back to keeping the chart fitted (Fit button, new fitKey). */
|
|
6284
|
+
const resetToFit = () => {
|
|
6285
|
+
autoFitRef.current = true;
|
|
6286
|
+
fitToView();
|
|
6287
|
+
};
|
|
6288
|
+
const zoomBy = (factor) => {
|
|
6289
|
+
if (!svgRef.current || !zoomRef.current) return;
|
|
6290
|
+
autoFitRef.current = false;
|
|
6221
6291
|
const svg = d3.select(svgRef.current);
|
|
6222
|
-
svg.
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6292
|
+
if (prefersReducedMotion()) svg.interrupt().call(zoomRef.current.scaleBy, factor);
|
|
6293
|
+
else svg.transition().duration(250).call(zoomRef.current.scaleBy, factor);
|
|
6294
|
+
};
|
|
6295
|
+
useEffect(() => {
|
|
6296
|
+
onSelectRef.current = onSelect;
|
|
6297
|
+
}, [onSelect]);
|
|
6298
|
+
useEffect(() => {
|
|
6299
|
+
const svgEl = svgRef.current;
|
|
6300
|
+
if (!svgEl) return;
|
|
6301
|
+
const svg = d3.select(svgEl);
|
|
6302
|
+
const viewport = svg.append("g").attr("class", "oc-viewport");
|
|
6303
|
+
viewport.append("g").attr("class", "oc-links");
|
|
6304
|
+
viewport.append("g").attr("class", "oc-nodes");
|
|
6305
|
+
const zoom = d3.zoom().scaleExtent([MIN_SCALE, MAX_SCALE]).extent(() => {
|
|
6306
|
+
const { width, height } = getSize();
|
|
6307
|
+
return [[0, 0], [Math.max(1, width), Math.max(1, height)]];
|
|
6308
|
+
}).clickDistance(5).filter((event) => {
|
|
6309
|
+
if (event.type === "dblclick") return false;
|
|
6310
|
+
const e = event;
|
|
6311
|
+
return (!e.ctrlKey || event.type === "wheel") && !e.button;
|
|
6312
|
+
}).on("zoom", (event) => {
|
|
6313
|
+
viewport.attr("transform", event.transform.toString());
|
|
6314
|
+
if (!programmaticRef.current) autoFitRef.current = false;
|
|
6315
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6316
|
+
scaleFrameRef.current = requestAnimationFrame(() => setScale(event.transform.k));
|
|
6317
|
+
});
|
|
6318
|
+
zoomRef.current = zoom;
|
|
6319
|
+
svg.call(zoom);
|
|
6320
|
+
const ro = new ResizeObserver(() => {
|
|
6321
|
+
const size = getSize();
|
|
6322
|
+
if (size.width === 0 || size.height === 0) return;
|
|
6323
|
+
const last = lastSizeRef.current;
|
|
6324
|
+
lastSizeRef.current = size;
|
|
6325
|
+
if (autoFitRef.current || pendingFitRef.current || !last) {
|
|
6326
|
+
fitToView();
|
|
6327
|
+
return;
|
|
6328
|
+
}
|
|
6329
|
+
const dx = (size.width - last.width) / 2;
|
|
6330
|
+
const dy = (size.height - last.height) / 2;
|
|
6331
|
+
if (dx === 0 && dy === 0) return;
|
|
6332
|
+
const t = d3.zoomTransform(svgEl);
|
|
6333
|
+
applyTransform(d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
|
|
6334
|
+
});
|
|
6335
|
+
ro.observe(svgEl);
|
|
6336
|
+
return () => {
|
|
6337
|
+
ro.disconnect();
|
|
6338
|
+
cancelAnimationFrame(scaleFrameRef.current);
|
|
6339
|
+
svg.interrupt().on(".zoom", null);
|
|
6340
|
+
svg.selectAll("*").remove();
|
|
6341
|
+
zoomRef.current = null;
|
|
6342
|
+
};
|
|
6343
|
+
}, []);
|
|
6344
|
+
useEffect(() => {
|
|
6345
|
+
const svgEl = svgRef.current;
|
|
6346
|
+
if (!svgEl) return;
|
|
6347
|
+
const svg = d3.select(svgEl);
|
|
6348
|
+
const linkLayer = svg.select("g.oc-links");
|
|
6349
|
+
const nodeLayer = svg.select("g.oc-nodes");
|
|
6350
|
+
if (nodes.length === 0) {
|
|
6351
|
+
linkLayer.selectAll("*").remove();
|
|
6352
|
+
nodeLayer.selectAll("*").remove();
|
|
6353
|
+
treeBoundsRef.current = null;
|
|
6354
|
+
return;
|
|
6355
|
+
}
|
|
6356
|
+
const root = d3.hierarchy(buildD3Tree(nodes));
|
|
6357
|
+
const pointRoot = d3.tree().nodeSize([280, 185])(root);
|
|
6358
|
+
const descendants = pointRoot.descendants();
|
|
6227
6359
|
treeBoundsRef.current = {
|
|
6228
6360
|
minX: d3.min(descendants, (d) => d.x) ?? 0,
|
|
6229
6361
|
maxX: d3.max(descendants, (d) => d.x) ?? 0,
|
|
6230
6362
|
minY: d3.min(descendants, (d) => d.y) ?? 0,
|
|
6231
6363
|
maxY: d3.max(descendants, (d) => d.y) ?? 0
|
|
6232
6364
|
};
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
svg.call(zoom);
|
|
6237
|
-
g.selectAll("path").data(root.links().filter((l) => !l.source.data.isSyntheticRoot)).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6238
|
-
const sx = link.source.x ?? 0;
|
|
6239
|
-
const sy = link.source.y ?? 0;
|
|
6240
|
-
const tx = link.target.x ?? 0;
|
|
6241
|
-
const ty = link.target.y ?? 0;
|
|
6242
|
-
return `M${sx},${sy}V${(sy + ty) / 2}H${tx}V${ty}`;
|
|
6365
|
+
linkLayer.selectAll("path").data(pointRoot.links().filter((l) => !l.source.data.isSyntheticRoot), (l) => `${l.source.data.originalId}>${l.target.data.originalId}`).join("path").attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1.5).attr("d", (link) => {
|
|
6366
|
+
const mid = (link.source.y + link.target.y) / 2;
|
|
6367
|
+
return `M${link.source.x},${link.source.y}V${mid}H${link.target.x}V${link.target.y}`;
|
|
6243
6368
|
});
|
|
6244
|
-
const node =
|
|
6245
|
-
|
|
6246
|
-
|
|
6369
|
+
const node = nodeLayer.selectAll("g.oc-node").data(descendants.filter((d) => !d.data.isSyntheticRoot), (d) => d.data.originalId).join((enter) => {
|
|
6370
|
+
const g = enter.append("g").attr("class", "oc-node");
|
|
6371
|
+
g.append("title");
|
|
6372
|
+
g.append("rect").attr("class", "oc-card").attr("x", -125).attr("y", -42).attr("width", NODE_W).attr("height", NODE_H).attr("rx", 14).attr("filter", "drop-shadow(0 6px 12px rgba(15,23,42,0.08))");
|
|
6373
|
+
g.append("rect").attr("class", "oc-ring").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none");
|
|
6374
|
+
g.append("text").attr("class", "oc-name").attr("text-anchor", "middle").attr("y", -8).attr("font-size", 13).attr("font-weight", 800);
|
|
6375
|
+
g.append("text").attr("class", "oc-desc").attr("text-anchor", "middle").attr("y", 16).attr("font-size", 11).attr("font-weight", 600);
|
|
6376
|
+
return g;
|
|
6377
|
+
}).attr("transform", (d) => `translate(${d.x},${d.y})`).classed("is-root", (d) => d.data.isRoot).classed("is-muted", (d) => d.data.isMuted).classed("is-selected", (d) => d.data.originalId === selectedIdRef.current).attr("aria-label", (d) => d.data.description ? `${d.data.name}, ${d.data.description}` : d.data.name).on("click", (_e, d) => onSelectRef.current?.(d.data.originalId)).on("keydown", (event, d) => {
|
|
6378
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
6379
|
+
event.preventDefault();
|
|
6380
|
+
onSelectRef.current?.(d.data.originalId);
|
|
6381
|
+
}).on("focus", (_e, d) => {
|
|
6382
|
+
if (!zoomRef.current) return;
|
|
6383
|
+
const { width: W, height: H } = getSize();
|
|
6384
|
+
const [sx, sy] = d3.zoomTransform(svgEl).apply([d.x, d.y]);
|
|
6385
|
+
const margin = 24;
|
|
6386
|
+
if (sx >= margin && sx <= W - margin && sy >= margin && sy <= H - margin) return;
|
|
6387
|
+
autoFitRef.current = false;
|
|
6388
|
+
svg.interrupt().call(zoomRef.current.translateTo, d.x, d.y);
|
|
6247
6389
|
});
|
|
6248
|
-
node.
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
})
|
|
6252
|
-
node.
|
|
6253
|
-
|
|
6254
|
-
if (selectedId) node.filter((d) => d.data.originalId === selectedId).append("rect").attr("x", -128).attr("y", -45).attr("width", 256).attr("height", 90).attr("rx", 16).attr("fill", "none").attr("stroke", "#6366f1").attr("stroke-width", 2.5).attr("opacity", .6);
|
|
6255
|
-
requestAnimationFrame(() => {
|
|
6256
|
-
fitToView();
|
|
6390
|
+
node.select("title").text((d) => d.data.description ? `${d.data.name}\n${d.data.description}` : d.data.name);
|
|
6391
|
+
node.select("text.oc-name").each(function(d) {
|
|
6392
|
+
fitText(this, d.data.name, TEXT_MAX_W, 13);
|
|
6393
|
+
});
|
|
6394
|
+
node.select("text.oc-desc").each(function(d) {
|
|
6395
|
+
fitText(this, d.data.description, TEXT_MAX_W, 11);
|
|
6257
6396
|
});
|
|
6397
|
+
if (autoFitRef.current || pendingFitRef.current) fitToView();
|
|
6398
|
+
}, [nodes]);
|
|
6399
|
+
useEffect(() => {
|
|
6400
|
+
if (!svgRef.current) return;
|
|
6401
|
+
d3.select(svgRef.current).selectAll("g.oc-node").attr("tabindex", interactive ? 0 : null).attr("role", interactive ? "button" : null);
|
|
6402
|
+
}, [interactive, nodes]);
|
|
6403
|
+
useEffect(() => {
|
|
6404
|
+
selectedIdRef.current = selectedId;
|
|
6405
|
+
if (!svgRef.current) return;
|
|
6406
|
+
d3.select(svgRef.current).selectAll("g.oc-node").classed("is-selected", (d) => d.data.originalId === selectedId).attr("aria-pressed", interactive ? (d) => String(d.data.originalId === selectedId) : null);
|
|
6258
6407
|
}, [
|
|
6259
|
-
nodes,
|
|
6260
6408
|
selectedId,
|
|
6261
|
-
|
|
6409
|
+
interactive,
|
|
6410
|
+
nodes
|
|
6262
6411
|
]);
|
|
6412
|
+
const fitKeyRef = useRef(fitKey);
|
|
6263
6413
|
useEffect(() => {
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
};
|
|
6276
|
-
const resetZoom = () => {
|
|
6277
|
-
fitToView();
|
|
6414
|
+
if (fitKeyRef.current === fitKey) return;
|
|
6415
|
+
fitKeyRef.current = fitKey;
|
|
6416
|
+
resetToFit();
|
|
6417
|
+
}, [fitKey]);
|
|
6418
|
+
const handleKeyDown = (event) => {
|
|
6419
|
+
if (event.ctrlKey || event.metaKey || event.altKey || isEmpty) return;
|
|
6420
|
+
if (event.key === "+" || event.key === "=") zoomBy(BUTTON_ZOOM_STEP);
|
|
6421
|
+
else if (event.key === "-" || event.key === "_") zoomBy(1 / BUTTON_ZOOM_STEP);
|
|
6422
|
+
else if (event.key === "0") resetToFit();
|
|
6423
|
+
else return;
|
|
6424
|
+
event.preventDefault();
|
|
6278
6425
|
};
|
|
6426
|
+
const atMax = scale >= 1.7990000000000002;
|
|
6427
|
+
const atMin = scale <= .051000000000000004;
|
|
6279
6428
|
const controlBtnClass = [
|
|
6280
6429
|
"appearance-none border-0 bg-none font-[inherit]",
|
|
6281
6430
|
"flex items-center justify-center rounded-lg p-2",
|
|
6282
|
-
"text-slate-
|
|
6283
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30"
|
|
6431
|
+
"text-slate-500 transition hover:bg-slate-100 hover:text-slate-700",
|
|
6432
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue/30",
|
|
6433
|
+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
|
6284
6434
|
].join(" ");
|
|
6285
6435
|
return /* @__PURE__ */ jsxs("div", {
|
|
6286
|
-
className: `relative w-full overflow-hidden bg-slate-50/50 ${className}`,
|
|
6436
|
+
className: `relative w-full overflow-hidden bg-slate-50/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-blue/30 ${className}`,
|
|
6287
6437
|
style: { height },
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6438
|
+
tabIndex: isEmpty ? void 0 : 0,
|
|
6439
|
+
"aria-keyshortcuts": isEmpty ? void 0 : "+ - 0",
|
|
6440
|
+
onKeyDown: handleKeyDown,
|
|
6441
|
+
children: [
|
|
6442
|
+
/* @__PURE__ */ jsx("style", { children: `
|
|
6443
|
+
#${uid} .oc-card { fill: #fff; stroke: #e2e8f0; stroke-width: 1; transition: stroke 120ms ease; }
|
|
6444
|
+
#${uid} .oc-name { fill: #0f172a; }
|
|
6445
|
+
#${uid} .oc-desc { fill: #64748b; }
|
|
6446
|
+
#${uid} .oc-node.is-root .oc-card { fill: #0f172a; stroke: #0f172a; }
|
|
6447
|
+
#${uid} .oc-node.is-root .oc-name { fill: #fff; }
|
|
6448
|
+
#${uid} .oc-node.is-root .oc-desc { fill: #cbd5e1; }
|
|
6449
|
+
#${uid} .oc-node.is-muted .oc-card { fill: #f8fafc; stroke: #94a3b8; stroke-dasharray: 5 4; }
|
|
6450
|
+
#${uid} .oc-node.is-muted .oc-name { fill: #475569; }
|
|
6451
|
+
#${uid} .oc-node.is-muted .oc-desc { fill: #94a3b8; }
|
|
6452
|
+
#${uid} .oc-node.is-root.is-muted .oc-card { fill: #334155; stroke: #94a3b8; }
|
|
6453
|
+
#${uid} .oc-node.is-root.is-muted .oc-name { fill: #e2e8f0; }
|
|
6454
|
+
#${uid} .oc-node.is-root.is-muted .oc-desc { fill: #cbd5e1; }
|
|
6455
|
+
#${uid} .oc-ring { stroke: #6366f1; stroke-width: 2.5; opacity: 0; pointer-events: none; }
|
|
6456
|
+
#${uid}.is-interactive .oc-node { cursor: pointer; }
|
|
6457
|
+
#${uid}.is-interactive .oc-node:hover .oc-card { stroke: #a5b4fc; stroke-width: 1.5; }
|
|
6458
|
+
#${uid} .oc-node.is-selected .oc-card,
|
|
6459
|
+
#${uid}.is-interactive .oc-node.is-selected:hover .oc-card { stroke: #6366f1; stroke-width: 2.5; }
|
|
6460
|
+
#${uid} .oc-node.is-selected .oc-ring { opacity: 0.6; }
|
|
6461
|
+
#${uid} .oc-node:focus { outline: none; }
|
|
6462
|
+
#${uid} .oc-node:focus-visible .oc-ring { opacity: 1; stroke: #2563eb; }
|
|
6463
|
+
` }),
|
|
6464
|
+
!isEmpty && /* @__PURE__ */ jsxs("div", {
|
|
6465
|
+
className: "absolute right-4 top-4 z-10 flex flex-col items-center gap-1 rounded-lg border border-slate-100 bg-white p-1.5 shadow-md",
|
|
6466
|
+
children: [
|
|
6467
|
+
/* @__PURE__ */ jsx("button", {
|
|
6468
|
+
type: "button",
|
|
6469
|
+
className: controlBtnClass,
|
|
6470
|
+
onClick: () => zoomBy(BUTTON_ZOOM_STEP),
|
|
6471
|
+
disabled: atMax,
|
|
6472
|
+
"aria-label": "Zoom in",
|
|
6473
|
+
title: "Zoom in (+)",
|
|
6474
|
+
children: /* @__PURE__ */ jsx(ZoomIn, {
|
|
6475
|
+
size: 16,
|
|
6476
|
+
"aria-hidden": "true"
|
|
6477
|
+
})
|
|
6478
|
+
}),
|
|
6479
|
+
/* @__PURE__ */ jsxs("span", {
|
|
6480
|
+
className: "min-w-[3rem] select-none text-center text-[11px] font-semibold tabular-nums text-slate-500",
|
|
6481
|
+
children: [Math.round(scale * 100), "%"]
|
|
6482
|
+
}),
|
|
6483
|
+
/* @__PURE__ */ jsx("button", {
|
|
6484
|
+
type: "button",
|
|
6485
|
+
className: controlBtnClass,
|
|
6486
|
+
onClick: () => zoomBy(1 / BUTTON_ZOOM_STEP),
|
|
6487
|
+
disabled: atMin,
|
|
6488
|
+
"aria-label": "Zoom out",
|
|
6489
|
+
title: "Zoom out (-)",
|
|
6490
|
+
children: /* @__PURE__ */ jsx(ZoomOut, {
|
|
6491
|
+
size: 16,
|
|
6492
|
+
"aria-hidden": "true"
|
|
6493
|
+
})
|
|
6494
|
+
}),
|
|
6495
|
+
/* @__PURE__ */ jsx("div", {
|
|
6496
|
+
className: "my-0.5 h-px w-6 bg-slate-100",
|
|
6318
6497
|
"aria-hidden": "true"
|
|
6498
|
+
}),
|
|
6499
|
+
/* @__PURE__ */ jsx("button", {
|
|
6500
|
+
type: "button",
|
|
6501
|
+
className: controlBtnClass,
|
|
6502
|
+
onClick: resetToFit,
|
|
6503
|
+
"aria-label": "Fit to screen",
|
|
6504
|
+
title: "Fit to screen (0)",
|
|
6505
|
+
children: /* @__PURE__ */ jsx(Scan, {
|
|
6506
|
+
size: 16,
|
|
6507
|
+
"aria-hidden": "true"
|
|
6508
|
+
})
|
|
6319
6509
|
})
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6510
|
+
]
|
|
6511
|
+
}),
|
|
6512
|
+
isEmpty && /* @__PURE__ */ jsx("div", {
|
|
6513
|
+
className: "absolute inset-0 flex items-center justify-center p-6 text-center text-sm font-semibold text-slate-500",
|
|
6514
|
+
children: "No units to display"
|
|
6515
|
+
}),
|
|
6516
|
+
/* @__PURE__ */ jsx("svg", {
|
|
6517
|
+
ref: svgRef,
|
|
6518
|
+
id: uid,
|
|
6519
|
+
width: "100%",
|
|
6520
|
+
height: "100%",
|
|
6521
|
+
className: `block touch-none ${interactive ? "is-interactive" : ""}`,
|
|
6522
|
+
role: interactive ? "group" : "img",
|
|
6523
|
+
"aria-label": ariaLabel
|
|
6524
|
+
})
|
|
6525
|
+
]
|
|
6331
6526
|
});
|
|
6332
6527
|
}
|
|
6333
6528
|
//#endregion
|
|
@@ -13,25 +13,30 @@
|
|
|
13
13
|
* - 250 × 84 px rounded rectangles (rx=14)
|
|
14
14
|
* - Root (depth 0): dark navy fill (#0f172a), white text
|
|
15
15
|
* - Children: white fill (#fff), slate border (#e2e8f0)
|
|
16
|
+
* - Muted tone: dashed border, slate-50 fill, muted text (inactive units)
|
|
16
17
|
* - drop-shadow: 0 6px 12px rgba(15,23,42,0.08)
|
|
17
|
-
* - Name: 13px, font-weight 800, centered
|
|
18
|
+
* - Name: 13px, font-weight 800, centered, ellipsis + tooltip when too long
|
|
18
19
|
* - Description: 11px, font-weight 600, #64748b / #cbd5e1 (root)
|
|
19
|
-
* - Selected: indigo border overlay ring
|
|
20
|
+
* - Hover: indigo-300 border; Selected: indigo border + overlay ring
|
|
20
21
|
* Connectors:
|
|
21
22
|
* - Stepped orthogonal path: M→V→H→V
|
|
22
23
|
* - Stroke: #cbd5e1, stroke-width 1.5
|
|
23
24
|
* Canvas:
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* - Fits the tree to the viewport until the user zooms or pans; after that
|
|
26
|
+
* the user's view is kept across re-renders, data refreshes and resizes
|
|
27
|
+
* (e.g. a modal hiding the page scrollbar). "Fit to screen" or a new
|
|
28
|
+
* `fitKey` hands control back to auto-fit.
|
|
29
|
+
* - Zoom: scaleExtent [0.05, 1.8]; wheel / pinch / buttons / keyboard
|
|
30
|
+
* - Pan: mouse/touch drag (a drag never counts as a node click)
|
|
27
31
|
* - Layout: nodeSize [280, 185] — horizontal × vertical spacing
|
|
28
32
|
* Controls:
|
|
29
|
-
* -
|
|
33
|
+
* - Zoom in, zoom %, Zoom out, Fit to screen (top-right overlay panel)
|
|
30
34
|
*
|
|
31
35
|
* Accessibility:
|
|
32
|
-
* - SVG has role="img"
|
|
33
|
-
* -
|
|
34
|
-
* - Keyboard:
|
|
36
|
+
* - SVG has an aria-label; role="img" when read-only, role="group" when nodes are selectable
|
|
37
|
+
* - Selectable nodes are focusable buttons (Tab, Enter / Space to select)
|
|
38
|
+
* - Keyboard on the chart: + / = zoom in, - zoom out, 0 fit to screen
|
|
39
|
+
* - Respects prefers-reduced-motion for button zoom transitions
|
|
35
40
|
* - Note: full ARIA tree semantics are not possible in an SVG diagram;
|
|
36
41
|
* use OrgUnitTree for a fully keyboard-accessible list-based alternative
|
|
37
42
|
*
|
|
@@ -50,6 +55,11 @@ export interface OrgChartNode {
|
|
|
50
55
|
label: string;
|
|
51
56
|
/** Secondary text line inside the node. */
|
|
52
57
|
description?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Visual tone. `'muted'` renders a dashed, greyed-out card — use it for
|
|
60
|
+
* inactive or archived units. Default: `'default'`.
|
|
61
|
+
*/
|
|
62
|
+
tone?: 'default' | 'muted';
|
|
53
63
|
/** Child nodes. */
|
|
54
64
|
children?: OrgChartNode[];
|
|
55
65
|
}
|
|
@@ -62,8 +72,14 @@ export interface OrgChartProps {
|
|
|
62
72
|
nodes: OrgChartNode[];
|
|
63
73
|
/** Currently selected node id — renders an indigo ring on the node. */
|
|
64
74
|
selectedId?: string;
|
|
65
|
-
/** Called when a node is clicked. */
|
|
75
|
+
/** Called when a node is clicked (or activated with Enter / Space). */
|
|
66
76
|
onSelect?: (id: string) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Changing this value re-fits the tree to the viewport, discarding the
|
|
79
|
+
* user's zoom/pan. Use it when the chart shows a new context (e.g. a
|
|
80
|
+
* different company scope). Data refreshes alone keep the user's view.
|
|
81
|
+
*/
|
|
82
|
+
fitKey?: string | number;
|
|
67
83
|
/**
|
|
68
84
|
* Container height. Default: '75vh' — matching the production CompanyStructurePage.
|
|
69
85
|
* Pass a fixed pixel value for demos: '480px'.
|
|
@@ -74,4 +90,4 @@ export interface OrgChartProps {
|
|
|
74
90
|
/** Additional class on the root container div. */
|
|
75
91
|
className?: string;
|
|
76
92
|
}
|
|
77
|
-
export declare function OrgChart({ nodes, selectedId, onSelect, height, 'aria-label': ariaLabel, className, }: OrgChartProps): import("react").JSX.Element;
|
|
93
|
+
export declare function OrgChart({ nodes, selectedId, onSelect, fitKey, height, 'aria-label': ariaLabel, className, }: OrgChartProps): import("react").JSX.Element;
|