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:
amosblomqvist
2026-09-17 09:35:11 +03:00
parent b24bce3f83
commit 2f4b9c82e4
4 changed files with 80 additions and 28 deletions
+21 -19
View File
@@ -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();
}
}
}
+28 -9
View File
@@ -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) }))}
/>
<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
dropdown for the bitmap resolution (it's a discrete
choice, not a free number). Server-persisted like the
@@ -376,6 +368,33 @@ function GraphSettingsControl({
))}
</select>
</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>
{/* Physics: the live sim's forces. Every value feeds the SAME
engine the server settle runs (createGraphSimulation);