Cluster labels: topmost layer + user-facing size setting
- 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.
This commit is contained in:
+21
-19
@@ -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
|
* 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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1357,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) {
|
||||||
@@ -1413,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts
|
|||||||
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,
|
||||||
@@ -279,15 +280,6 @@ 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
|
||||||
@@ -376,6 +368,33 @@ 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"
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -214,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,
|
||||||
};
|
};
|
||||||
@@ -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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user