From 2f4b9c82e407eb86f72004404b3ae71851672e08 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 09:35:11 +0300 Subject: [PATCH] Cluster labels: topmost layer + user-facing size setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Draw order: the cluster-label block moves from layer 5 to THE LAST layer of draw() — labels can never be occluded by node labels or hover names (user 2026-09-17; chose "topmost" after reassessing that discs were never actually on top — the white-out over discs is source-over compositing of the 85%-alpha text over near-white node fill, an optical contrast flip, not a draw-order bug). - clusterLabelSize: new persisted setting (default 14 = the old REGION_LABEL_SIZE constant, so rendering is pixel-identical). Screen px at ANY zoom (drawn at size/k — camera-compensated, UI-like, same law as before). Log-slider range [7, 28] puts the default dead-center per the house slider law. Sanitized: old persisted files (no field) fall back to 14. - Panel: the Cluster labels checkbox moves from the Labels section into Regions (user: cluster settings live with regions), joined by the new "Cluster label size" slider. Labels stay decoupled from the Background colors compute switch, as designed on 2026-09-16. --- components/graph-canvas.ts | 40 ++++++++++++++++++----------------- components/graph-view.tsx | 37 ++++++++++++++++++++++++-------- src/core/render-settings.ts | 14 ++++++++++++ tests/render-settings.test.ts | 17 +++++++++++++++ 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index e1deaa5..c0f8b08 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -169,7 +169,6 @@ 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; @@ -1357,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) { @@ -1413,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(); + } } } diff --git a/components/graph-view.tsx b/components/graph-view.tsx index d564d60..3c65f17 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -69,6 +69,7 @@ import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts import { ARROW_LENGTH_RANGE, CHARGE_RANGE, + CLUSTER_LABEL_SIZE_RANGE, CLUSTER_PULL_RANGE, DEPTH_BASE_RADIUS_RANGE, DEPTH_SPACING_RANGE, @@ -279,15 +280,6 @@ function GraphSettingsControl({ onChange={(p) => changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} /> - {/* 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 @@ -376,6 +368,33 @@ function GraphSettingsControl({ ))} + {/* 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). */} + + + changeSettings((cur) => ({ + ...cur, + clusterLabelSize: fromSliderPosition(p, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max), + }))} + /> {/* Physics: the live sim's forces. Every value feeds the SAME engine the server settle runs (createGraphSimulation); diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index 3444440..02becac 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -34,6 +34,10 @@ export interface GraphRenderSettings { labelSize: number; /** Toggle for the cluster region labels (hulls always draw). */ 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 * since 2026-09-13 (user request) — was session-only dev state. */ 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), 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. regions: sanitizeRegionTuning(r.regions), physics: sanitizePhysicsTuning(r.physics), @@ -214,6 +224,7 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = { labelZoom: 0.5, labelSize: 2, clusterLabels: true, + clusterLabelSize: 14, regions: DEFAULT_REGION_TUNING, physics: DEFAULT_PHYSICS_TUNING, }; @@ -229,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 ARROW_LENGTH_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_MAX = 4; // slider far right: labels visible at fit zoom diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index f12430c..7602bea 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -10,6 +10,7 @@ import { describe, it as test } from "node:test"; import { ARROW_LENGTH_RANGE, CHARGE_RANGE, + CLUSTER_LABEL_SIZE_RANGE, CLUSTER_PULL_RANGE, DEFAULT_RENDER_SETTINGS, DEPTH_BASE_RADIUS_RANGE, @@ -53,6 +54,7 @@ describe("sanitizeRenderSettings", () => { assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom); 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", () => { @@ -63,6 +65,7 @@ describe("sanitizeRenderSettings", () => { labelZoom: 100, labelSize: "big" as unknown as number, 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.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min @@ -70,6 +73,19 @@ describe("sanitizeRenderSettings", () => { assert.equal(s.labelZoom, LABEL_ZOOM_MAX); assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset 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)", () => { @@ -88,6 +104,7 @@ describe("sanitizeRenderSettings", () => { labelZoom: 0.25, labelSize: 4, clusterLabels: false, + clusterLabelSize: 20, regions: DEFAULT_REGION_TUNING, physics: DEFAULT_PHYSICS_TUNING, };