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,
|
sanitizeRegionTuning,
|
||||||
type PhysicsTuning,
|
type PhysicsTuning,
|
||||||
type RegionTuning,
|
type RegionTuning,
|
||||||
|
type EdgeType,
|
||||||
INCREMENTAL_GRAVITY,
|
INCREMENTAL_GRAVITY,
|
||||||
LINK_DISTANCE,
|
LINK_DISTANCE,
|
||||||
type DepthAnchorMode,
|
type DepthAnchorMode,
|
||||||
@@ -88,6 +89,10 @@ export interface CanvasNode {
|
|||||||
export interface CanvasEdge {
|
export interface CanvasEdge {
|
||||||
from: string;
|
from: string;
|
||||||
to: 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). */
|
/** 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
|
* live-physics frame pays one tiny blit instead of 32 clusters × 4
|
||||||
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
|
* 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_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
|
/** 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
|
/** The arrowhead geometry for a directed edge a→b: shaft endpoints
|
||||||
* (clear of both node discs) and the head triangle (tip on the target
|
* (clear of both node discs) and the head triangle (tip on the target
|
||||||
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */
|
* 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(
|
export function arrowGeometry(
|
||||||
a: Pt,
|
a: Pt,
|
||||||
b: Pt,
|
b: Pt,
|
||||||
fromRadius: number,
|
fromRadius: number,
|
||||||
toRadius: number,
|
toRadius: number,
|
||||||
headLength: number,
|
headLength: number,
|
||||||
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
|
): ArrowGeom | null {
|
||||||
const dx = b.x - a.x;
|
const dx = b.x - a.x;
|
||||||
const dy = b.y - a.y;
|
const dy = b.y - a.y;
|
||||||
const len = Math.hypot(dx, dy);
|
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
|
/** Nearest node within maxDist of a screen point (linear scan — µs at
|
||||||
* our scale; a spatial grid only earns its keep past ~10k nodes). */
|
* our scale; a spatial grid only earns its keep past ~10k nodes). */
|
||||||
export function pickNearest<T extends { id: string; x: number; y: number }>(
|
export function pickNearest<T extends { id: string; x: number; y: number }>(
|
||||||
@@ -582,6 +597,13 @@ export class GraphCanvasRenderer {
|
|||||||
setRegionTuning(input: Partial<RegionTuning>): void {
|
setRegionTuning(input: Partial<RegionTuning>): void {
|
||||||
this.regionTuning = sanitizeRegionTuning(input);
|
this.regionTuning = sanitizeRegionTuning(input);
|
||||||
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
|
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();
|
this.markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,14 +960,13 @@ export class GraphCanvasRenderer {
|
|||||||
// Screen px at fit zoom → world units.
|
// Screen px at fit zoom → world units.
|
||||||
const s = 1 / this.kFit;
|
const s = 1 / this.kFit;
|
||||||
const nodeR = settings.nodeSize * s;
|
const nodeR = settings.nodeSize * s;
|
||||||
|
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
|
||||||
this.world = {
|
this.world = {
|
||||||
nodeR,
|
nodeR,
|
||||||
glowOuter: nodeR * GLOW_SCALE,
|
glowOuter: nodeR * GLOW_SCALE,
|
||||||
labelSize: settings.labelSize * s,
|
labelSize: settings.labelSize * s,
|
||||||
labelGap: LABEL_GAP * 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 visible = this.visible;
|
||||||
const isVisible = (id: string) => !visible || visible.has(id);
|
const isVisible = (id: string) => !visible || visible.has(id);
|
||||||
|
|
||||||
@@ -958,7 +979,11 @@ export class GraphCanvasRenderer {
|
|||||||
}
|
}
|
||||||
this.nodesPath = nodesPath;
|
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 shafts = new Path2D();
|
||||||
const heads = new Path2D();
|
const heads = new Path2D();
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
@@ -1070,6 +1095,47 @@ export class GraphCanvasRenderer {
|
|||||||
return;
|
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
|
// Bitmap sizing: longest world span maps to `bitmap` px. The rect
|
||||||
// is padded by the stroke half-width (dilation) plus the blur
|
// is padded by the stroke half-width (dilation) plus the blur
|
||||||
// reach so nothing clips at the bitmap edge.
|
// reach so nothing clips at the bitmap edge.
|
||||||
@@ -1123,17 +1189,10 @@ export class GraphCanvasRenderer {
|
|||||||
rctx.lineCap = "round";
|
rctx.lineCap = "round";
|
||||||
rctx.lineWidth = (dilationW * 2) / cell;
|
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
|
// The region IS the dilated skeleton: intra-cluster edges as
|
||||||
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
|
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
|
||||||
// — they double-counted where nodes sit on edges (user-identified
|
// — they double-counted where nodes sit on edges (user-identified
|
||||||
// hot spots, when clusters were edge-based).
|
// hot spots, when clusters were edge-based).
|
||||||
this.regionMeta = [];
|
|
||||||
data.clusters.forEach((cluster, ci) => {
|
data.clusters.forEach((cluster, ci) => {
|
||||||
const hue = clusterHue(cluster.id);
|
const hue = clusterHue(cluster.id);
|
||||||
// Full-opacity strokes: alpha is applied ONCE at the very end
|
// Full-opacity strokes: alpha is applied ONCE at the very end
|
||||||
@@ -1145,9 +1204,6 @@ export class GraphCanvasRenderer {
|
|||||||
// contribute exactly 1.0.
|
// contribute exactly 1.0.
|
||||||
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`;
|
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`;
|
||||||
rctx.beginPath();
|
rctx.beginPath();
|
||||||
let members = 0;
|
|
||||||
let sumX = 0;
|
|
||||||
let sumY = 0;
|
|
||||||
// Endpoints actually covered by a stroke's round caps.
|
// Endpoints actually covered by a stroke's round caps.
|
||||||
const touched = new Set<string>();
|
const touched = new Set<string>();
|
||||||
for (const e of data.edges) {
|
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.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
|
||||||
rctx.fill();
|
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
|
// THE blur + alpha (in that order, by design — user-decided
|
||||||
@@ -1313,24 +1356,6 @@ export class GraphCanvasRenderer {
|
|||||||
ctx.fillStyle = data.colors.node;
|
ctx.fillStyle = data.colors.node;
|
||||||
ctx.fill(this.nodesPath);
|
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
|
// 6. Node labels — one global gate, faded together. World-scaled
|
||||||
// with the graph (they grow as you zoom in, Obsidian-style).
|
// with the graph (they grow as you zoom in, Obsidian-style).
|
||||||
if (nodeAlpha > 0.01) {
|
if (nodeAlpha > 0.01) {
|
||||||
@@ -1369,5 +1394,26 @@ export class GraphCanvasRenderer {
|
|||||||
ctx.restore();
|
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 {
|
import {
|
||||||
ARROW_LENGTH_RANGE,
|
ARROW_LENGTH_RANGE,
|
||||||
CHARGE_RANGE,
|
CHARGE_RANGE,
|
||||||
|
CLUSTER_LABEL_SIZE_RANGE,
|
||||||
CLUSTER_PULL_RANGE,
|
CLUSTER_PULL_RANGE,
|
||||||
DEPTH_BASE_RADIUS_RANGE,
|
DEPTH_BASE_RADIUS_RANGE,
|
||||||
DEPTH_SPACING_RANGE,
|
DEPTH_SPACING_RANGE,
|
||||||
@@ -166,6 +167,7 @@ function SettingSlider({
|
|||||||
value,
|
value,
|
||||||
display,
|
display,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
min: number;
|
min: number;
|
||||||
@@ -175,9 +177,12 @@ function SettingSlider({
|
|||||||
/** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */
|
/** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */
|
||||||
display?: string;
|
display?: string;
|
||||||
onChange: (v: number) => void;
|
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 (
|
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">
|
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
<span className="tabular-nums">{display ?? value}</span>
|
<span className="tabular-nums">{display ?? value}</span>
|
||||||
@@ -188,6 +193,7 @@ function SettingSlider({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className="h-1.5 w-full accent-primary"
|
className="h-1.5 w-full accent-primary"
|
||||||
/>
|
/>
|
||||||
@@ -280,26 +286,33 @@ function GraphSettingsControl({
|
|||||||
onChange={(p) =>
|
onChange={(p) =>
|
||||||
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
|
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
|
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
|
||||||
dropdown for the bitmap resolution (it's a discrete
|
dropdown for the bitmap resolution (it's a discrete
|
||||||
choice, not a free number). Server-persisted like the
|
choice, not a free number). Server-persisted like the
|
||||||
sizes; every range end was sweep-validated on the real
|
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">
|
<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">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Regions
|
Regions
|
||||||
</span>
|
</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
|
<SettingSlider
|
||||||
label="Dilation"
|
label="Dilation"
|
||||||
|
disabled={!regionTuning.enabled}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -311,9 +324,12 @@ function GraphSettingsControl({
|
|||||||
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
|
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>
|
<span>Blur</span>
|
||||||
<select
|
<select
|
||||||
|
disabled={!regionTuning.enabled}
|
||||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||||
value={regionTuning.blur}
|
value={regionTuning.blur}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -332,6 +348,7 @@ function GraphSettingsControl({
|
|||||||
</label>
|
</label>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Edge alpha"
|
label="Edge alpha"
|
||||||
|
disabled={!regionTuning.enabled}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -343,9 +360,12 @@ function GraphSettingsControl({
|
|||||||
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
|
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>
|
<span>Bitmap resolution</span>
|
||||||
<select
|
<select
|
||||||
|
disabled={!regionTuning.enabled}
|
||||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||||
value={regionTuning.bitmap}
|
value={regionTuning.bitmap}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -362,6 +382,34 @@ function GraphSettingsControl({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
{/* Physics: the live sim's forces. Every value feeds the SAME
|
{/* Physics: the live sim's forces. Every value feeds the SAME
|
||||||
engine the server settle runs (createGraphSimulation);
|
engine the server settle runs (createGraphSimulation);
|
||||||
@@ -370,67 +418,13 @@ function GraphSettingsControl({
|
|||||||
sweep-validated ranges (src/core/render-settings.ts):
|
sweep-validated ranges (src/core/render-settings.ts):
|
||||||
range ends and all four corners settle cleanly — no NaN,
|
range ends and all four corners settle cleanly — no NaN,
|
||||||
no explosion, no non-settle. Repulsion is displayed as
|
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">
|
<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">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Physics
|
Physics
|
||||||
</span>
|
</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
|
<SettingSlider
|
||||||
label="Node repulsion"
|
label="Node repulsion"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -509,6 +503,69 @@ function GraphSettingsControl({
|
|||||||
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -754,7 +811,7 @@ export function GraphView() {
|
|||||||
y: positions.get(n.id)?.y ?? 0,
|
y: positions.get(n.id)?.y ?? 0,
|
||||||
label: n.name,
|
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 })),
|
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
|
||||||
colors: {
|
colors: {
|
||||||
node: tokenHex(NODE_COLOR_TOKEN),
|
node: tokenHex(NODE_COLOR_TOKEN),
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
# Vision — The edge glyph: follows vs supports
|
# Vision — The edge glyph: follows vs supports
|
||||||
|
|
||||||
Ideation settled 2026-09-14. Not yet planned, not yet assigned. Companion: `vision.md` § "Edges" (the relation types) and § "Rendering".
|
Ideation settled 2026-09-14. **WITHDRAWN 2026-09-15**: both shipped variants of
|
||||||
|
the double-shaft glyph (tails converging on a shared head; two complete arrows
|
||||||
|
offset ±half-gap) were rejected by the owner live — the first "two arrows
|
||||||
|
feeding one point", the second "two arrows next to each other, not coherent".
|
||||||
|
Dashed was already rejected 2026-09-14 (dash patterns crawl under the constant
|
||||||
|
screen-space stroke width). **The relation still needs a differentiator —
|
||||||
|
approach open.** The constraints below that are NOT glyph-specific still stand.
|
||||||
|
Companion: `vision.md` § "Edges" (the relation types) and § "Rendering".
|
||||||
|
|
||||||
One line: the two relation types become visually distinct — **`follows` renders as a double-shaft arrow** (two parallel tails side by side, the implication glyph ⟹), **`supports` stays a single line**. Arrows keep their existing direction: prerequisite → dependent.
|
One line (withdrawn): the two relation types become visually distinct —
|
||||||
|
**`follows` renders as a double-shaft arrow** (two parallel tails side by
|
||||||
|
side, the implication glyph ⟹), **`supports` stays a single line**. Arrows
|
||||||
|
keep their existing direction: prerequisite → dependent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,7 +31,8 @@ Dash patterns break under our **constant screen-space stroke width**: the dash a
|
|||||||
|
|
||||||
The renderer is the custom 2D canvas; edges draw in the edge layer with shafts + arrowheads from `arrowGeometry` (sigma-style head, prerequisite → dependent). For `follows`:
|
The renderer is the custom 2D canvas; edges draw in the edge layer with shafts + arrowheads from `arrowGeometry` (sigma-style head, prerequisite → dependent). For `follows`:
|
||||||
|
|
||||||
- Both shafts offset along the edge normal by a **fixed screen-space gap**, converging on a **shared single arrowhead**.
|
- Both shafts offset along the edge normal by a **fixed screen-space gap**.
|
||||||
|
- **REVISED 2026-09-15 (owner rejected the first cut):** the converged variant — two tails merging into one shared arrowhead — read as two arrows feeding a single point, not as parallel strokes. The glyph is two **complete regular arrows** (own shaft + own head each) translated ±half-gap along the normal: parallel shafts AND parallel heads, nothing converges.
|
||||||
- The gap is proportional to the constant stroke width — a constant visual ratio at every zoom level.
|
- The gap is proportional to the constant stroke width — a constant visual ratio at every zoom level.
|
||||||
- **No LOD collapse (withdrawn 2026-09-14).** The earlier idea of collapsing to a single shaft below a stroke-width threshold assumed width shrinks with zoom — it doesn't; stroke width is constant on screen, so the double shaft renders identically at every zoom level. Simpler than proposed: nothing to gate.
|
- **No LOD collapse (withdrawn 2026-09-14).** The earlier idea of collapsing to a single shaft below a stroke-width threshold assumed width shrinks with zoom — it doesn't; stroke width is constant on screen, so the double shaft renders identically at every zoom level. Simpler than proposed: nothing to gate.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface GraphRenderSettings {
|
|||||||
labelSize: number;
|
labelSize: number;
|
||||||
/** Toggle for the cluster region labels (hulls always draw). */
|
/** Toggle for the cluster region labels (hulls always draw). */
|
||||||
clusterLabels: boolean;
|
clusterLabels: boolean;
|
||||||
|
/** Cluster label font size, px on screen (camera-compensated —
|
||||||
|
* constant at any zoom, unlike labelSize which scales with the
|
||||||
|
* graph). Defaults to the old REGION_LABEL_SIZE constant (14). */
|
||||||
|
clusterLabelSize: number;
|
||||||
/** Region rendering (the blurred cluster tints). Server-persisted
|
/** Region rendering (the blurred cluster tints). Server-persisted
|
||||||
* since 2026-09-13 (user request) — was session-only dev state. */
|
* since 2026-09-13 (user request) — was session-only dev state. */
|
||||||
regions: RegionTuning;
|
regions: RegionTuning;
|
||||||
@@ -65,6 +69,12 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings {
|
|||||||
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max),
|
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max),
|
||||||
clusterLabels:
|
clusterLabels:
|
||||||
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
||||||
|
clusterLabelSize: num(
|
||||||
|
r.clusterLabelSize,
|
||||||
|
DEFAULT_RENDER_SETTINGS.clusterLabelSize,
|
||||||
|
CLUSTER_LABEL_SIZE_RANGE.min,
|
||||||
|
CLUSTER_LABEL_SIZE_RANGE.max,
|
||||||
|
),
|
||||||
// Nested sections (2026-09-13): old files lack them → defaults.
|
// Nested sections (2026-09-13): old files lack them → defaults.
|
||||||
regions: sanitizeRegionTuning(r.regions),
|
regions: sanitizeRegionTuning(r.regions),
|
||||||
physics: sanitizePhysicsTuning(r.physics),
|
physics: sanitizePhysicsTuning(r.physics),
|
||||||
@@ -100,6 +110,11 @@ export interface RegionTuning {
|
|||||||
* of any count never stack (DECIDED 2026-09-14 — per-stroke alpha
|
* of any count never stack (DECIDED 2026-09-14 — per-stroke alpha
|
||||||
* accumulated through source-over and double-counted orphans). */
|
* accumulated through source-over and double-counted orphans). */
|
||||||
edgeAlpha: number;
|
edgeAlpha: number;
|
||||||
|
/** THE off switch (user 2026-09-15): false = the renderer never runs
|
||||||
|
* the region build — no splat, no blur, no readback, no bitmap
|
||||||
|
* allocation, no blit. Compute skipped, not just paint hidden.
|
||||||
|
* Cluster labels are decoupled and follow `clusterLabels`. */
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const;
|
export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const;
|
||||||
@@ -110,6 +125,7 @@ export const DEFAULT_REGION_TUNING: RegionTuning = {
|
|||||||
blur: 6,
|
blur: 6,
|
||||||
bitmap: 384,
|
bitmap: 384,
|
||||||
edgeAlpha: 0.35,
|
edgeAlpha: 0.35,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Nearest member of a fixed option set (dropdowns have no free values). */
|
/** Nearest member of a fixed option set (dropdowns have no free values). */
|
||||||
@@ -129,6 +145,9 @@ export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undef
|
|||||||
blur: snapToOption(num(input?.blur, d.blur), REGION_BLUR_OPTIONS),
|
blur: snapToOption(num(input?.blur, d.blur), REGION_BLUR_OPTIONS),
|
||||||
bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS),
|
bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS),
|
||||||
edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), REGION_EDGE_ALPHA_RANGE.min), 1),
|
edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), REGION_EDGE_ALPHA_RANGE.min), 1),
|
||||||
|
// Old persisted files predate the switch: missing → on (the look
|
||||||
|
// users tuned); only an explicit false turns regions off.
|
||||||
|
enabled: typeof input?.enabled === "boolean" ? input.enabled : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +224,7 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
|
|||||||
labelZoom: 0.5,
|
labelZoom: 0.5,
|
||||||
labelSize: 2,
|
labelSize: 2,
|
||||||
clusterLabels: true,
|
clusterLabels: true,
|
||||||
|
clusterLabelSize: 14,
|
||||||
regions: DEFAULT_REGION_TUNING,
|
regions: DEFAULT_REGION_TUNING,
|
||||||
physics: DEFAULT_PHYSICS_TUNING,
|
physics: DEFAULT_PHYSICS_TUNING,
|
||||||
};
|
};
|
||||||
@@ -220,6 +240,9 @@ export const NODE_SIZE_RANGE = { min: 0.5, max: 8 } as const;
|
|||||||
export const EDGE_WIDTH_RANGE = { min: 0.25, max: 4 } as const;
|
export const EDGE_WIDTH_RANGE = { min: 0.25, max: 4 } as const;
|
||||||
export const ARROW_LENGTH_RANGE = { min: 0.5, max: 8 } as const;
|
export const ARROW_LENGTH_RANGE = { min: 0.5, max: 8 } as const;
|
||||||
export const LABEL_SIZE_RANGE = { min: 0.5, max: 8 } as const;
|
export const LABEL_SIZE_RANGE = { min: 0.5, max: 8 } as const;
|
||||||
|
// Screen-px at ANY zoom (drawn at size / k). 14 = geometric mean — old
|
||||||
|
// REGION_LABEL_SIZE default, so the panel slider starts dead-center.
|
||||||
|
export const CLUSTER_LABEL_SIZE_RANGE = { min: 7, max: 28 } as const;
|
||||||
export const LABEL_ZOOM_MIN = 0.0625; // slider far left: labels only very deep in
|
export const LABEL_ZOOM_MIN = 0.0625; // slider far left: labels only very deep in
|
||||||
export const LABEL_ZOOM_MAX = 4; // slider far right: labels visible at fit zoom
|
export const LABEL_ZOOM_MAX = 4; // slider far right: labels visible at fit zoom
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ describe("arrowGeometry", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("pickNearest (distance-check picking)", () => {
|
describe("pickNearest (distance-check picking)", () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{ id: "a", x: 10, y: 10 },
|
{ id: "a", x: 10, y: 10 },
|
||||||
@@ -225,3 +226,4 @@ describe("sanitizePhysicsTuning (physics controls)", () => {
|
|||||||
assert.equal(t.velocityDecay, 0.9);
|
assert.equal(t.velocityDecay, 0.9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describe, it as test } from "node:test";
|
|||||||
import {
|
import {
|
||||||
ARROW_LENGTH_RANGE,
|
ARROW_LENGTH_RANGE,
|
||||||
CHARGE_RANGE,
|
CHARGE_RANGE,
|
||||||
|
CLUSTER_LABEL_SIZE_RANGE,
|
||||||
CLUSTER_PULL_RANGE,
|
CLUSTER_PULL_RANGE,
|
||||||
DEFAULT_RENDER_SETTINGS,
|
DEFAULT_RENDER_SETTINGS,
|
||||||
DEPTH_BASE_RADIUS_RANGE,
|
DEPTH_BASE_RADIUS_RANGE,
|
||||||
@@ -53,6 +54,7 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
|
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
|
||||||
assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom);
|
assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom);
|
||||||
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
|
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
|
||||||
|
assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // old files → 14
|
||||||
});
|
});
|
||||||
|
|
||||||
test("out-of-range values clamp; wrong types reset", () => {
|
test("out-of-range values clamp; wrong types reset", () => {
|
||||||
@@ -63,6 +65,7 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
labelZoom: 100,
|
labelZoom: 100,
|
||||||
labelSize: "big" as unknown as number,
|
labelSize: "big" as unknown as number,
|
||||||
clusterLabels: "yes" as unknown as boolean,
|
clusterLabels: "yes" as unknown as boolean,
|
||||||
|
clusterLabelSize: "huge" as unknown as number,
|
||||||
});
|
});
|
||||||
assert.equal(s.nodeSize, NODE_SIZE_RANGE.max); // clamped to max
|
assert.equal(s.nodeSize, NODE_SIZE_RANGE.max); // clamped to max
|
||||||
assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min
|
assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min
|
||||||
@@ -70,6 +73,19 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
assert.equal(s.labelZoom, LABEL_ZOOM_MAX);
|
assert.equal(s.labelZoom, LABEL_ZOOM_MAX);
|
||||||
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset
|
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset
|
||||||
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
|
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
|
||||||
|
assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // NaN-safe reset
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clusterLabelSize clamps to its own range (slider law)", () => {
|
||||||
|
// Default 14 is the geometric mean of [7, 28] → dead-center slider.
|
||||||
|
assert.equal(
|
||||||
|
Math.sqrt(CLUSTER_LABEL_SIZE_RANGE.min * CLUSTER_LABEL_SIZE_RANGE.max),
|
||||||
|
DEFAULT_RENDER_SETTINGS.clusterLabelSize,
|
||||||
|
);
|
||||||
|
const s = sanitizeRenderSettings({ clusterLabelSize: 999 });
|
||||||
|
assert.equal(s.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.max);
|
||||||
|
const s2 = sanitizeRenderSettings({ clusterLabelSize: 1 });
|
||||||
|
assert.equal(s2.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the low ends accept small sizes (log-slider floors)", () => {
|
test("the low ends accept small sizes (log-slider floors)", () => {
|
||||||
@@ -88,6 +104,7 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
labelZoom: 0.25,
|
labelZoom: 0.25,
|
||||||
labelSize: 4,
|
labelSize: 4,
|
||||||
clusterLabels: false,
|
clusterLabels: false,
|
||||||
|
clusterLabelSize: 20,
|
||||||
regions: DEFAULT_REGION_TUNING,
|
regions: DEFAULT_REGION_TUNING,
|
||||||
physics: DEFAULT_PHYSICS_TUNING,
|
physics: DEFAULT_PHYSICS_TUNING,
|
||||||
};
|
};
|
||||||
@@ -214,4 +231,16 @@ describe("slider mapping (log scale, default-centered)", () => {
|
|||||||
// Stored blur 0 (the old off zone) snaps back into the set.
|
// Stored blur 0 (the old off zone) snaps back into the set.
|
||||||
assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2);
|
assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enabled is the compute off switch: only an explicit false turns it off", () => {
|
||||||
|
// Default on; old persisted files predate the field (missing → on);
|
||||||
|
// garbage falls back to on (fail toward the tuned look).
|
||||||
|
assert.equal(DEFAULT_REGION_TUNING.enabled, true);
|
||||||
|
assert.equal(sanitizeRegionTuning({}).enabled, true);
|
||||||
|
assert.equal(sanitizeRegionTuning({ enabled: false }).enabled, false);
|
||||||
|
assert.equal(sanitizeRegionTuning({ enabled: true }).enabled, true);
|
||||||
|
assert.equal(sanitizeRegionTuning({ enabled: "no" as unknown as boolean }).enabled, true);
|
||||||
|
// An explicit false survives even alongside garbage in other fields.
|
||||||
|
assert.equal(sanitizeRegionTuning({ enabled: false, blur: 99, bitmap: 99999 }).enabled, false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user