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,
};