diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index d058059..3056716 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -623,6 +623,10 @@ export class GraphCanvasRenderer { private builtVersion = -1; // version the cache was built at private nodesPath: Path2D | null = null; private shaftsPath: Path2D = new Path2D(); + /** Supports shafts draw THINNER (owner, 2026-09-29): their own path + + * stroke pass at edgeWidth × supportsWidth. Arrowheads share the one + * heads path at full geometry (owner pick: shaft only). */ + private supportsShaftsPath: Path2D = new Path2D(); private headsPath: Path2D = new Path2D(); private regionCanvas: HTMLCanvasElement | null = null; /** World rect the bitmap covers (drawImage target coords). */ @@ -1232,11 +1236,14 @@ export class GraphCanvasRenderer { // 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 — - // except the supports draw toggle (owner, 2026-09-29): off skips - // the paths entirely (a draw-only hide; the sim still feels them). + // approach. Two owner rulings 2026-09-29: the supports draw toggle + // (off skips the paths entirely — a draw-only hide; the sim still + // feels them) and the supports SHAFT thickness split (supports get + // their own path stroked at edgeWidth × supportsWidth; heads stay + // full-geometry in the one shared path). const arrowLen = settings.arrowLength; const shafts = new Path2D(); + const supportsShafts = new Path2D(); const heads = new Path2D(); for (const e of data.edges) { if (!settings.showSupports && e.type === "supports") continue; @@ -1246,14 +1253,16 @@ export class GraphCanvasRenderer { if (!a || !b) continue; const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen); if (!g) continue; - shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y); - shafts.lineTo(g.shaftTo.x, g.shaftTo.y); + const path = e.type === "supports" ? supportsShafts : shafts; + path.moveTo(g.shaftFrom.x, g.shaftFrom.y); + path.lineTo(g.shaftTo.x, g.shaftTo.y); heads.moveTo(g.tip.x, g.tip.y); heads.lineTo(g.left.x, g.left.y); heads.lineTo(g.right.x, g.right.y); heads.closePath(); } this.shaftsPath = shafts; + this.supportsShaftsPath = supportsShafts; this.headsPath = heads; // Cluster regions are NOT built here: they rasterize into their own @@ -1620,12 +1629,20 @@ export class GraphCanvasRenderer { // 3. Edges: shafts + arrowheads from cached paths; width constant on // screen (÷ k), geometry in world units (scales with the graph). + // Supports shafts: a second, thinner stroke (owner, 2026-09-29) — + // heads share the one fill at full geometry (shaft-only ruling). + // A true 0-width stroke is undefined on canvas: the ratio's 0 end + // floors to a hairline (0.5px on screen — hiding is the toggle's). if (settings.edgeWidth > 0) { ctx.strokeStyle = data.colors.edge; ctx.fillStyle = data.colors.edge; ctx.lineWidth = settings.edgeWidth / k; ctx.lineCap = "round"; ctx.stroke(this.shaftsPath); + if (settings.showSupports) { + ctx.lineWidth = Math.max(settings.edgeWidth * settings.supportsWidth, 0.5) / k; + ctx.stroke(this.supportsShaftsPath); + } ctx.fill(this.headsPath); } diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 4e55601..c1910a0 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -53,7 +53,8 @@ * five sliders (node size, edge width, arrowhead length, label size, the * zoom threshold), the supports-edges draw toggle (2026-09-29: off hides * supports edges from the canvas — draw-only; physics and detail cards - * still see them), and the cluster-labels checkbox. SERVER-persisted + * still see them) with its shaft-thickness slider (0–1 × Edge width, + * default 0.5; heads stay full), and the cluster-labels checkbox. SERVER-persisted * (data/render-settings.json via /api/graph/render-settings): loaded * once after mount, saved debounced on every user change — one source * of truth shared by every browser. The label-zoom slider is log-scaled @@ -532,7 +533,7 @@ function GraphSettingsControl({ /> {/* Supports draw toggle (owner, 2026-09-29): hides the single-line supports edges from the canvas — follows (⟹-meant) stay. Draw-only: - physics + detail cards still see them. Last row of the FIRST + physics + detail cards still see them. Last rows of the FIRST section; the border-t below starts Regions. */} + {/* Supports shaft thickness (owner, 2026-09-29): a LINEAR 0–1 + ratio of Edge width (not a ladder — it's a ratio; default 0.5 + dead-center anyway, step 0.1 = 11 stops). Grayed out by the + toggle above (the Dilation pattern); 0 floors to a hairline in + the renderer — arrowheads never scale (shaft-only ruling). */} + changeSettings((cur) => ({ ...cur, supportsWidth: v }))} + /> {/* 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 diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index 3fc09a4..600b33b 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -59,6 +59,14 @@ export interface GraphRenderSettings { * them (settings never shift the graph: camera, one frame), and node * detail cards still list them. */ showSupports: boolean; + /** Relative SHAFT thickness of supports edges (owner, 2026-09-29): + * a 0–1 multiplier of edgeWidth — default 0.5, the single-line + * relation reads as the lighter one. SHAFT ONLY: arrowheads keep + * full Arrowhead-size geometry (owner pick). Linear, not a ladder — + * it's a ratio; exactly 0 floors to a hairline in the renderer + * (canvas cannot stroke a true 0-width line; hiding is the toggle's + * job). */ + supportsWidth: number; /** Toggle for the cluster region labels (hulls always draw). */ clusterLabels: boolean; /** Cluster label font size, px on screen (camera-compensated — @@ -102,6 +110,12 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings { labelSize: legacySizes ? DEFAULT_RENDER_SETTINGS.labelSize : ladd(r.labelSize, LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize), showSupports: typeof r.showSupports === "boolean" ? r.showSupports : DEFAULT_RENDER_SETTINGS.showSupports, + // Linear ratio, not a ladder: real numbers clamp to [0, 1]; anything + // else resets to the default. + supportsWidth: + typeof r.supportsWidth === "number" && Number.isFinite(r.supportsWidth) + ? clamp(r.supportsWidth, 0, 1) + : DEFAULT_RENDER_SETTINGS.supportsWidth, clusterLabels: typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, clusterLabelSize: ladd( @@ -270,6 +284,7 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = { labelZoom: 4, labelSize: 3, showSupports: true, + supportsWidth: 0.5, clusterLabels: true, clusterLabelSize: 14, regions: DEFAULT_REGION_TUNING, diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index 011ca1a..a01ee74 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -119,6 +119,7 @@ describe("sanitizeRenderSettings", () => { labelZoom: 2, labelSize: 4, showSupports: false, + supportsWidth: 0.3, clusterLabels: false, clusterLabelSize: 20, regions: DEFAULT_REGION_TUNING, @@ -137,6 +138,19 @@ describe("sanitizeRenderSettings", () => { assert.equal(sanitizeRenderSettings({ showSupports: 0 }).showSupports, true); }); + test("supportsWidth (2026-09-29): linear 0–1 ratio of edgeWidth, default 0.5", () => { + // The supports shaft-thickness multiplier: LINEAR (not a ladder — + // it's a ratio), real numbers clamp to [0, 1], anything else resets + // to the default. The 0 end floors to a hairline in the renderer. + assert.equal(sanitizeRenderSettings({}).supportsWidth, 0.5); + assert.equal(sanitizeRenderSettings({ supportsWidth: 0 }).supportsWidth, 0); + assert.equal(sanitizeRenderSettings({ supportsWidth: 1 }).supportsWidth, 1); + assert.equal(sanitizeRenderSettings({ supportsWidth: 0.37 }).supportsWidth, 0.37); + assert.equal(sanitizeRenderSettings({ supportsWidth: 1.7 }).supportsWidth, 1); + assert.equal(sanitizeRenderSettings({ supportsWidth: -0.3 }).supportsWidth, 0); + assert.equal(sanitizeRenderSettings({ supportsWidth: "thick" }).supportsWidth, 0.5); + }); + test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => { // data/render-settings.json on disk predates the nested sections — // those loads must get defaults, not crash, not shift anything.