Merge lh-graph refinement wave (feat/edge-glyph @ 66e6212) into main
Edge glyph (⟹ follows arrow) WITHDRAWN by owner after two live-rejected variants — CanvasEdge.type plumbing + ArrowGeom retained, differentiation approach still open (docs/vision-edge-glyph.md decision record). Keepers: regions.enabled "Background colors" compute off-switch (skips splat/blur/readback/blit), clusterLabelSize (default 14, topmost draw layer, screen-px) with Cluster labels toggle + size slider in the Regions section, dependency graying across the settings panels, physics panel reorder (general forces first, Depth anchor + conditionals last). Merged cleanly over master's coverage-chrome changes in graph-view.tsx (their gated fragment + the ungated GraphCoverageControl coexist); data/ scratch not carried (untracked, master's real render-settings.json picked up clusterLabelSize=14 via sanitizer fallback). Gates 488/488; live- verified: chrome intact, Background colors graying works, physics order, coverage panel opens (11 sessions).
This commit is contained in:
+92
-46
@@ -64,6 +64,7 @@ import {
|
||||
sanitizeRegionTuning,
|
||||
type PhysicsTuning,
|
||||
type RegionTuning,
|
||||
type EdgeType,
|
||||
INCREMENTAL_GRAVITY,
|
||||
LINK_DISTANCE,
|
||||
type DepthAnchorMode,
|
||||
@@ -88,6 +89,10 @@ export interface CanvasNode {
|
||||
export interface CanvasEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
/** Relation type: `follows` renders the double-shaft glyph (⟹),
|
||||
* `supports` the plain single line (DECIDED 2026-09-14,
|
||||
* docs/vision-edge-glyph.md). */
|
||||
type: EdgeType;
|
||||
}
|
||||
|
||||
/** One cluster region: member node ids + the LLM-coined label (or null). */
|
||||
@@ -164,7 +169,7 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel
|
||||
* live-physics frame pays one tiny blit instead of 32 clusters × 4
|
||||
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
|
||||
const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
|
||||
const REGION_LABEL_SIZE = 14;
|
||||
|
||||
|
||||
|
||||
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
|
||||
@@ -282,13 +287,22 @@ export function fitTransform(
|
||||
/** The arrowhead geometry for a directed edge a→b: shaft endpoints
|
||||
* (clear of both node discs) and the head triangle (tip on the target
|
||||
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */
|
||||
/** One complete arrow glyph: shaft + head triangle. */
|
||||
export interface ArrowGeom {
|
||||
shaftFrom: Pt;
|
||||
shaftTo: Pt;
|
||||
tip: Pt;
|
||||
left: Pt;
|
||||
right: Pt;
|
||||
}
|
||||
|
||||
export function arrowGeometry(
|
||||
a: Pt,
|
||||
b: Pt,
|
||||
fromRadius: number,
|
||||
toRadius: number,
|
||||
headLength: number,
|
||||
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
|
||||
): ArrowGeom | null {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
@@ -307,6 +321,7 @@ export function arrowGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** Nearest node within maxDist of a screen point (linear scan — µs at
|
||||
* our scale; a spatial grid only earns its keep past ~10k nodes). */
|
||||
export function pickNearest<T extends { id: string; x: number; y: number }>(
|
||||
@@ -582,6 +597,13 @@ export class GraphCanvasRenderer {
|
||||
setRegionTuning(input: Partial<RegionTuning>): void {
|
||||
this.regionTuning = sanitizeRegionTuning(input);
|
||||
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
|
||||
// Off switch turning off: drop the bitmap immediately so the
|
||||
// blit stops on the next frame — the rebuild itself skips all
|
||||
// compute (buildRegions).
|
||||
if (!this.regionTuning.enabled) {
|
||||
this.regionCanvas = null;
|
||||
this.regionRect = null;
|
||||
}
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
@@ -938,14 +960,13 @@ export class GraphCanvasRenderer {
|
||||
// Screen px at fit zoom → world units.
|
||||
const s = 1 / this.kFit;
|
||||
const nodeR = settings.nodeSize * s;
|
||||
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
|
||||
this.world = {
|
||||
nodeR,
|
||||
glowOuter: nodeR * GLOW_SCALE,
|
||||
labelSize: settings.labelSize * s,
|
||||
labelGap: LABEL_GAP * s,
|
||||
};
|
||||
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
|
||||
const arrowLen = settings.arrowLength * s;
|
||||
const visible = this.visible;
|
||||
const isVisible = (id: string) => !visible || visible.has(id);
|
||||
|
||||
@@ -958,7 +979,11 @@ export class GraphCanvasRenderer {
|
||||
}
|
||||
this.nodesPath = nodesPath;
|
||||
|
||||
// Edge shafts + arrowheads: tip lands on the target disc edge.
|
||||
// Edge shafts + arrowheads: tip lands on the target disc edge. Both
|
||||
// relation types draw the same arrow for now — the double-shaft
|
||||
// glyph was rejected (2026-09-15); differentiation is pending a new
|
||||
// approach. CanvasEdge.type is plumbed for whatever comes next.
|
||||
const arrowLen = settings.arrowLength * s;
|
||||
const shafts = new Path2D();
|
||||
const heads = new Path2D();
|
||||
for (const e of data.edges) {
|
||||
@@ -1070,6 +1095,47 @@ export class GraphCanvasRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Which cluster is a node in (for intra-cluster edges)?
|
||||
const clusterOf = new Map<string, number>();
|
||||
data.clusters.forEach((c, i) => {
|
||||
for (const id of c.members) clusterOf.set(id, i);
|
||||
});
|
||||
|
||||
// Label anchors ALWAYS compute (cheap — a centroid pass, no canvas):
|
||||
// cluster labels follow their own `clusterLabels` setting and must
|
||||
// survive the region paint being turned off.
|
||||
this.regionMeta = [];
|
||||
data.clusters.forEach((cluster) => {
|
||||
let members = 0;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
for (const id of cluster.members) {
|
||||
if (!isVisible(id)) continue;
|
||||
const n = this.nodeById.get(id);
|
||||
if (!n) continue;
|
||||
members++;
|
||||
sumX += n.x;
|
||||
sumY += n.y;
|
||||
}
|
||||
if (members > 0) {
|
||||
this.regionMeta.push({
|
||||
label: cluster.label || null,
|
||||
cx: sumX / members,
|
||||
cy: sumY / members,
|
||||
hue: clusterHue(cluster.id),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// THE off switch (user 2026-09-15): compute is skipped, not just
|
||||
// paint hidden — no splat, no blur, no readback, no bitmap, no
|
||||
// blit. Everything below this return is the bitmap path.
|
||||
if (!tuning.enabled) {
|
||||
this.regionCanvas = null;
|
||||
this.regionRect = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bitmap sizing: longest world span maps to `bitmap` px. The rect
|
||||
// is padded by the stroke half-width (dilation) plus the blur
|
||||
// reach so nothing clips at the bitmap edge.
|
||||
@@ -1123,17 +1189,10 @@ export class GraphCanvasRenderer {
|
||||
rctx.lineCap = "round";
|
||||
rctx.lineWidth = (dilationW * 2) / cell;
|
||||
|
||||
// Which cluster is a node in (for intra-cluster edges)?
|
||||
const clusterOf = new Map<string, number>();
|
||||
data.clusters.forEach((c, i) => {
|
||||
for (const id of c.members) clusterOf.set(id, i);
|
||||
});
|
||||
|
||||
// The region IS the dilated skeleton: intra-cluster edges as
|
||||
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
|
||||
// — they double-counted where nodes sit on edges (user-identified
|
||||
// hot spots, when clusters were edge-based).
|
||||
this.regionMeta = [];
|
||||
data.clusters.forEach((cluster, ci) => {
|
||||
const hue = clusterHue(cluster.id);
|
||||
// Full-opacity strokes: alpha is applied ONCE at the very end
|
||||
@@ -1145,9 +1204,6 @@ export class GraphCanvasRenderer {
|
||||
// contribute exactly 1.0.
|
||||
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`;
|
||||
rctx.beginPath();
|
||||
let members = 0;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
// Endpoints actually covered by a stroke's round caps.
|
||||
const touched = new Set<string>();
|
||||
for (const e of data.edges) {
|
||||
@@ -1185,19 +1241,6 @@ export class GraphCanvasRenderer {
|
||||
rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
|
||||
rctx.fill();
|
||||
}
|
||||
|
||||
// Label anchor: member centroid (world coords).
|
||||
for (const id of cluster.members) {
|
||||
if (!isVisible(id)) continue;
|
||||
const n = this.nodeById.get(id);
|
||||
if (!n) continue;
|
||||
members++;
|
||||
sumX += n.x;
|
||||
sumY += n.y;
|
||||
}
|
||||
if (members > 0) {
|
||||
this.regionMeta.push({ label: cluster.label || null, cx: sumX / members, cy: sumY / members, hue });
|
||||
}
|
||||
});
|
||||
|
||||
// THE blur + alpha (in that order, by design — user-decided
|
||||
@@ -1313,24 +1356,6 @@ export class GraphCanvasRenderer {
|
||||
ctx.fillStyle = data.colors.node;
|
||||
ctx.fill(this.nodesPath);
|
||||
|
||||
// 5. Cluster region labels — inverse-gated on THE zoom control.
|
||||
// Viewer-relative by design (UI, not graph content): drawn at the
|
||||
// world centroid with a font of REGION_LABEL_SIZE / k, which is a
|
||||
// constant 18px on screen at any zoom.
|
||||
if (settings.clusterLabels && regionAlpha > 0.01) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = regionAlpha;
|
||||
ctx.font = `${LABEL_WEIGHT} ${REGION_LABEL_SIZE / k}px ${data.fontFamily}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (const rm of this.regionMeta) {
|
||||
if (!rm.label) continue;
|
||||
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
|
||||
ctx.fillText(rm.label, rm.cx, rm.cy);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 6. Node labels — one global gate, faded together. World-scaled
|
||||
// with the graph (they grow as you zoom in, Obsidian-style).
|
||||
if (nodeAlpha > 0.01) {
|
||||
@@ -1369,5 +1394,26 @@ export class GraphCanvasRenderer {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Cluster region labels — THE TOPMOST layer (user 2026-09-16:
|
||||
// node labels and hover names drew over them before; region labels
|
||||
// must never be occluded by content). Inverse-gated on THE zoom
|
||||
// control (visible zoomed-out, fading as node labels come in).
|
||||
// Viewer-relative by design (UI, not graph content): drawn at the
|
||||
// world centroid with a font of clusterLabelSize / k — a constant
|
||||
// size on screen at any zoom (user-tunable since 2026-09-16).
|
||||
if (settings.clusterLabels && regionAlpha > 0.01) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = regionAlpha;
|
||||
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize / k}px ${data.fontFamily}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (const rm of this.regionMeta) {
|
||||
if (!rm.label) continue;
|
||||
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
|
||||
ctx.fillText(rm.label, rm.cx, rm.cy);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+128
-71
@@ -70,6 +70,7 @@ import { GraphCoverageControl } from "./graph-coverage.tsx";
|
||||
import {
|
||||
ARROW_LENGTH_RANGE,
|
||||
CHARGE_RANGE,
|
||||
CLUSTER_LABEL_SIZE_RANGE,
|
||||
CLUSTER_PULL_RANGE,
|
||||
DEPTH_BASE_RADIUS_RANGE,
|
||||
DEPTH_SPACING_RANGE,
|
||||
@@ -166,6 +167,7 @@ function SettingSlider({
|
||||
value,
|
||||
display,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
min: number;
|
||||
@@ -175,9 +177,12 @@ function SettingSlider({
|
||||
/** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */
|
||||
display?: string;
|
||||
onChange: (v: number) => void;
|
||||
/** Dependency graying (user 2026-09-17): children dim + lock when
|
||||
* their parent toggle/mode is off — consistently across sections. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<label className={`flex flex-col gap-1${disabled ? " opacity-40" : ""}`}>
|
||||
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
{label}
|
||||
<span className="tabular-nums">{display ?? value}</span>
|
||||
@@ -188,6 +193,7 @@ function SettingSlider({
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-1.5 w-full accent-primary"
|
||||
/>
|
||||
@@ -280,26 +286,33 @@ function GraphSettingsControl({
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
|
||||
/>
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Cluster labels</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.clusterLabels}
|
||||
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</label>
|
||||
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
|
||||
dropdown for the bitmap resolution (it's a discrete
|
||||
choice, not a free number). Server-persisted like the
|
||||
sizes; every range end was sweep-validated on the real
|
||||
graph. */}
|
||||
graph. The enable switch is a COMPUTE switch: off skips
|
||||
the whole build (splat/blur/readback/blit), not just
|
||||
the paint. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Regions
|
||||
</span>
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Background colors</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.regions.enabled}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
regions: { ...cur.regions, enabled: e.target.checked },
|
||||
}))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Dilation"
|
||||
disabled={!regionTuning.enabled}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
@@ -311,9 +324,12 @@ function GraphSettingsControl({
|
||||
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<label
|
||||
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
|
||||
>
|
||||
<span>Blur</span>
|
||||
<select
|
||||
disabled={!regionTuning.enabled}
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||
value={regionTuning.blur}
|
||||
onChange={(e) =>
|
||||
@@ -332,6 +348,7 @@ function GraphSettingsControl({
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Edge alpha"
|
||||
disabled={!regionTuning.enabled}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
@@ -343,9 +360,12 @@ function GraphSettingsControl({
|
||||
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<label
|
||||
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
|
||||
>
|
||||
<span>Bitmap resolution</span>
|
||||
<select
|
||||
disabled={!regionTuning.enabled}
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||
value={regionTuning.bitmap}
|
||||
onChange={(e) =>
|
||||
@@ -362,6 +382,34 @@ function GraphSettingsControl({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Cluster labels moved here 2026-09-16 (user: cluster
|
||||
stuff lives in the regions section). Decoupled from
|
||||
the Background colors switch above — labels follow
|
||||
their own clusterLabels toggle. Size is screen px
|
||||
(constant at any zoom, unlike node labels). */}
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Cluster labels</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.clusterLabels}
|
||||
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Cluster label size"
|
||||
disabled={!settings.clusterLabels}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(settings.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max)}
|
||||
display={sizeDisplay(settings.clusterLabelSize)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
clusterLabelSize: fromSliderPosition(p, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{/* Physics: the live sim's forces. Every value feeds the SAME
|
||||
engine the server settle runs (createGraphSimulation);
|
||||
@@ -370,67 +418,13 @@ function GraphSettingsControl({
|
||||
sweep-validated ranges (src/core/render-settings.ts):
|
||||
range ends and all four corners settle cleanly — no NaN,
|
||||
no explosion, no non-settle. Repulsion is displayed as
|
||||
the negative it is; cluster pull's far left = off. */}
|
||||
the negative it is; cluster pull's far left = off.
|
||||
Order (user 2026-09-17): general forces first; depth
|
||||
anchor + its gray-out-able controls last. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Physics
|
||||
</span>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
Depth anchor
|
||||
<select
|
||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||
value={physicsTuning.depthMode}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="vertical">Vertical (up)</option>
|
||||
<option value="radial">Radial (outward)</option>
|
||||
</select>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Depth strength"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthStrength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Depth spacing"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthSpacing)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Base ring radius"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthBaseRadius)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Node repulsion"
|
||||
min={0}
|
||||
@@ -509,6 +503,69 @@ function GraphSettingsControl({
|
||||
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
{/* Depth anchor LAST (user 2026-09-17): general
|
||||
forces first, then the mode + its conditional
|
||||
controls at the very bottom — the grayed group reads
|
||||
as a self-contained sub-section. */}
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
Depth anchor
|
||||
<select
|
||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||
value={physicsTuning.depthMode}
|
||||
onChange={(e) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="vertical">Vertical (up)</option>
|
||||
<option value="radial">Radial (outward)</option>
|
||||
</select>
|
||||
</label>
|
||||
<SettingSlider
|
||||
label="Depth strength"
|
||||
disabled={physicsTuning.depthMode === "off"}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthStrength)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Depth spacing"
|
||||
disabled={physicsTuning.depthMode === "off"}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthSpacing)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
<SettingSlider
|
||||
label="Base ring radius"
|
||||
disabled={physicsTuning.depthMode !== "radial"}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
|
||||
display={sizeDisplay(physicsTuning.depthBaseRadius)}
|
||||
onChange={(p) =>
|
||||
changeSettings((cur) => ({
|
||||
...cur,
|
||||
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -754,7 +811,7 @@ export function GraphView() {
|
||||
y: positions.get(n.id)?.y ?? 0,
|
||||
label: n.name,
|
||||
})),
|
||||
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })),
|
||||
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to, type: e.type })),
|
||||
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
|
||||
colors: {
|
||||
node: tokenHex(NODE_COLOR_TOKEN),
|
||||
|
||||
Reference in New Issue
Block a user