supports-thickness slider — relative shaft width for supports edges
A LINEAR 0–1 multiplier of Edge width (default 0.5, dead-center at step 0.1 — 11 stops like the ladder rows but ratio-valued), grayed out by the supports-edges toggle (the Dilation dependency pattern). Renderer splits supports shafts into their own Path2D and strokes a second pass at edgeWidth × supportsWidth; arrowheads stay full-geometry in the one shared path (owner pick: shaft only). A true 0-width stroke is undefined on canvas — the 0 end floors to a 0.5px on-screen hairline; hiding remains the toggle's job. Sanitizer: real numbers clamp to [0,1], everything else resets to 0.5; clamps pinned in render-settings.test.
This commit is contained in:
@@ -623,6 +623,10 @@ export class GraphCanvasRenderer {
|
|||||||
private builtVersion = -1; // version the cache was built at
|
private builtVersion = -1; // version the cache was built at
|
||||||
private nodesPath: Path2D | null = null;
|
private nodesPath: Path2D | null = null;
|
||||||
private shaftsPath: Path2D = new Path2D();
|
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 headsPath: Path2D = new Path2D();
|
||||||
private regionCanvas: HTMLCanvasElement | null = null;
|
private regionCanvas: HTMLCanvasElement | null = null;
|
||||||
/** World rect the bitmap covers (drawImage target coords). */
|
/** 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
|
// Edge shafts + arrowheads: tip lands on the target disc edge. Both
|
||||||
// relation types draw the same arrow for now — the double-shaft
|
// relation types draw the same arrow for now — the double-shaft
|
||||||
// glyph was rejected (2026-09-15); differentiation is pending a new
|
// glyph was rejected (2026-09-15); differentiation is pending a new
|
||||||
// approach. CanvasEdge.type is plumbed for whatever comes next —
|
// approach. Two owner rulings 2026-09-29: the supports draw toggle
|
||||||
// except the supports draw toggle (owner, 2026-09-29): off skips
|
// (off skips the paths entirely — a draw-only hide; the sim still
|
||||||
// the paths entirely (a draw-only hide; the sim still feels them).
|
// 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 arrowLen = settings.arrowLength;
|
||||||
const shafts = new Path2D();
|
const shafts = new Path2D();
|
||||||
|
const supportsShafts = new Path2D();
|
||||||
const heads = new Path2D();
|
const heads = new Path2D();
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
if (!settings.showSupports && e.type === "supports") continue;
|
if (!settings.showSupports && e.type === "supports") continue;
|
||||||
@@ -1246,14 +1253,16 @@ export class GraphCanvasRenderer {
|
|||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
|
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
|
||||||
if (!g) continue;
|
if (!g) continue;
|
||||||
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
|
const path = e.type === "supports" ? supportsShafts : shafts;
|
||||||
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
|
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.moveTo(g.tip.x, g.tip.y);
|
||||||
heads.lineTo(g.left.x, g.left.y);
|
heads.lineTo(g.left.x, g.left.y);
|
||||||
heads.lineTo(g.right.x, g.right.y);
|
heads.lineTo(g.right.x, g.right.y);
|
||||||
heads.closePath();
|
heads.closePath();
|
||||||
}
|
}
|
||||||
this.shaftsPath = shafts;
|
this.shaftsPath = shafts;
|
||||||
|
this.supportsShaftsPath = supportsShafts;
|
||||||
this.headsPath = heads;
|
this.headsPath = heads;
|
||||||
|
|
||||||
// Cluster regions are NOT built here: they rasterize into their own
|
// 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
|
// 3. Edges: shafts + arrowheads from cached paths; width constant on
|
||||||
// screen (÷ k), geometry in world units (scales with the graph).
|
// 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) {
|
if (settings.edgeWidth > 0) {
|
||||||
ctx.strokeStyle = data.colors.edge;
|
ctx.strokeStyle = data.colors.edge;
|
||||||
ctx.fillStyle = data.colors.edge;
|
ctx.fillStyle = data.colors.edge;
|
||||||
ctx.lineWidth = settings.edgeWidth / k;
|
ctx.lineWidth = settings.edgeWidth / k;
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.stroke(this.shaftsPath);
|
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);
|
ctx.fill(this.headsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@
|
|||||||
* five sliders (node size, edge width, arrowhead length, label size, the
|
* five sliders (node size, edge width, arrowhead length, label size, the
|
||||||
* zoom threshold), the supports-edges draw toggle (2026-09-29: off hides
|
* zoom threshold), the supports-edges draw toggle (2026-09-29: off hides
|
||||||
* supports edges from the canvas — draw-only; physics and detail cards
|
* 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
|
* (data/render-settings.json via /api/graph/render-settings): loaded
|
||||||
* once after mount, saved debounced on every user change — one source
|
* once after mount, saved debounced on every user change — one source
|
||||||
* of truth shared by every browser. The label-zoom slider is log-scaled
|
* 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 draw toggle (owner, 2026-09-29): hides the single-line
|
||||||
supports edges from the canvas — follows (⟹-meant) stay. Draw-only:
|
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. */}
|
section; the border-t below starts Regions. */}
|
||||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
|
||||||
<span>Supports edges</span>
|
<span>Supports edges</span>
|
||||||
@@ -544,6 +545,21 @@ function GraphSettingsControl({
|
|||||||
className="accent-primary"
|
className="accent-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{/* 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). */}
|
||||||
|
<SettingSlider
|
||||||
|
label="Supports thickness"
|
||||||
|
disabled={!settings.showSupports}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={settings.supportsWidth}
|
||||||
|
display={`${Math.round(settings.supportsWidth * 100) / 100}×`}
|
||||||
|
onChange={(v) => changeSettings((cur) => ({ ...cur, supportsWidth: v }))}
|
||||||
|
/>
|
||||||
{/* 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
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export interface GraphRenderSettings {
|
|||||||
* them (settings never shift the graph: camera, one frame), and node
|
* them (settings never shift the graph: camera, one frame), and node
|
||||||
* detail cards still list them. */
|
* detail cards still list them. */
|
||||||
showSupports: boolean;
|
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). */
|
/** Toggle for the cluster region labels (hulls always draw). */
|
||||||
clusterLabels: boolean;
|
clusterLabels: boolean;
|
||||||
/** Cluster label font size, px on screen (camera-compensated —
|
/** 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),
|
labelSize: legacySizes ? DEFAULT_RENDER_SETTINGS.labelSize : ladd(r.labelSize, LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize),
|
||||||
showSupports:
|
showSupports:
|
||||||
typeof r.showSupports === "boolean" ? r.showSupports : DEFAULT_RENDER_SETTINGS.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:
|
clusterLabels:
|
||||||
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
||||||
clusterLabelSize: ladd(
|
clusterLabelSize: ladd(
|
||||||
@@ -270,6 +284,7 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
|
|||||||
labelZoom: 4,
|
labelZoom: 4,
|
||||||
labelSize: 3,
|
labelSize: 3,
|
||||||
showSupports: true,
|
showSupports: true,
|
||||||
|
supportsWidth: 0.5,
|
||||||
clusterLabels: true,
|
clusterLabels: true,
|
||||||
clusterLabelSize: 14,
|
clusterLabelSize: 14,
|
||||||
regions: DEFAULT_REGION_TUNING,
|
regions: DEFAULT_REGION_TUNING,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
labelZoom: 2,
|
labelZoom: 2,
|
||||||
labelSize: 4,
|
labelSize: 4,
|
||||||
showSupports: false,
|
showSupports: false,
|
||||||
|
supportsWidth: 0.3,
|
||||||
clusterLabels: false,
|
clusterLabels: false,
|
||||||
clusterLabelSize: 20,
|
clusterLabelSize: 20,
|
||||||
regions: DEFAULT_REGION_TUNING,
|
regions: DEFAULT_REGION_TUNING,
|
||||||
@@ -137,6 +138,19 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
assert.equal(sanitizeRenderSettings({ showSupports: 0 }).showSupports, true);
|
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", () => {
|
test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => {
|
||||||
// data/render-settings.json on disk predates the nested sections —
|
// data/render-settings.json on disk predates the nested sections —
|
||||||
// those loads must get defaults, not crash, not shift anything.
|
// those loads must get defaults, not crash, not shift anything.
|
||||||
|
|||||||
Reference in New Issue
Block a user