Rename-shuffle fix: label-only channel + three hygiene items
The live-rename interlude routed label changes through a fresh snapshot object, which flowed setData -> geometry rebuild - and the rebuild treats the last-fetched position ledger as authoritative and cancels any pending drag commit, so a rename visibly rewound the whole graph whenever display had drifted (recent drag, mid-anneal, physics re-anneal). - Renderer gains setClusterLabels (a setPhysicsTuning sibling): pure applyClusterLabels patches the data-side cluster records and the built region meta (re-wrapping the changed entries with the same measure the build uses), markDirty only. Never touches nodes, the sim, positions, or the pending commit. The hull-silence guard travels inside it: a null-label cluster is a gone record - no rename can reach it. - The lh:chat-renamed listener stops building snapshot objects: renameSessionsPatch writes the sessions entry in place (pure display state - the detail card's provenance line), the cluster label goes through the new channel, and a tick bump re-renders the view. Identity preserved everywhere -> the [snapshot]-keyed setData feed cannot fire. - Hygiene (a): a fresh sim starts at alpha 1 and never decays while stopped - the first post-rebuild drag re-annealed the whole graph at full strength. buildSim now initializes alpha at the settle floor, so first-drag matches steady-state drag. setPhysicsTuning still rehearses alpha(1) when a full re-anneal is the point. - Hygiene (b): nodes missing from the position ledger used to dump at (0,0) via the ?? 0 fallback. seedMissingNodePositions seeds them on a deterministic ring around the known positions' centroid (FNV-1a hashed angle, golden-angle spiral offset). - Hygiene (c) is store-band (writePositionMap's writeFileSync -> tmp+rename): flagged for Platform, not crossed. Tests: applyClusterLabels suite (patch + re-wrap, hull silence, no-op, explicit null, unknown ids), renameSessionsPatch identity pins (sessions map and clusters array identity preserved - the contract that keeps setData from firing), seedMissingNodePositions determinism + ring math. Live-verified: mid-anneal rename shows a 4-world-unit max disc jump (no teleport), the commit PUT survives the rename, rest renames leave the disc build byte-identical, and a held first drag displaces discs ~14 units instead of re-forming the graph.
This commit is contained in:
@@ -218,6 +218,44 @@ export function wrapRegionLabel(
|
||||
lines.push(`${tail}…`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Label-only cluster patch (rename-shuffle fix, 2026-09-23): apply a set
|
||||
* of live label updates to the data-side cluster records AND the built
|
||||
* region meta (re-wrapping the changed entries with the provided
|
||||
* measure — the same wrap a geometry build would produce). The
|
||||
* hull-silence guard travels here: a cluster whose CURRENT label is
|
||||
* null is a gone record — no rename can reach it. Pure, in-place on the
|
||||
* provided arrays; returns whether anything changed (the caller only
|
||||
* redraws when it did). Exported for tests. */
|
||||
export function applyClusterLabels(
|
||||
clusters: { id: string; label: string | null }[],
|
||||
regionMeta: { id: string; label: string | null; lines: string[] | null }[],
|
||||
labels: ReadonlyMap<string, string | null>,
|
||||
maxWidth: number,
|
||||
maxLines: number,
|
||||
measure: (s: string) => number,
|
||||
): boolean {
|
||||
let changed = false;
|
||||
for (const cluster of clusters) {
|
||||
if (!labels.has(cluster.id)) continue;
|
||||
if (cluster.label == null) continue; // hull silence: gone records stay silent
|
||||
const label = labels.get(cluster.id) ?? null;
|
||||
if (label === cluster.label) continue;
|
||||
cluster.label = label;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return false;
|
||||
for (const rm of regionMeta) {
|
||||
if (!labels.has(rm.id)) continue;
|
||||
if (rm.label == null) continue;
|
||||
const label = labels.get(rm.id) ?? null;
|
||||
rm.label = label;
|
||||
rm.lines = label
|
||||
? wrapRegionLabel(label, maxWidth, maxLines, measure)
|
||||
: null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const LABEL_MAX_WIDTH = 170; // wrap width at the reference label size, world units
|
||||
const LABEL_GAP = 2.9; // world units between node edge and first label line (the 2px-at-fit reference ÷ 0.69)
|
||||
const LABEL_LINE_HEIGHT = 1.3;
|
||||
@@ -590,7 +628,7 @@ export class GraphCanvasRenderer {
|
||||
/** World rect the bitmap covers (drawImage target coords). */
|
||||
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
||||
/** Region labels: cluster centroid + hue, computed at splat build. */
|
||||
private regionMeta: { label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = [];
|
||||
private regionMeta: { id: string; label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = [];
|
||||
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
||||
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
||||
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
||||
@@ -771,6 +809,42 @@ export class GraphCanvasRenderer {
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
/** Label-only channel (rename-shuffle fix, 2026-09-23): live
|
||||
* cluster-label updates with NO setData. The rename path used to
|
||||
* build a fresh snapshot object, which flowed setData → geometry
|
||||
* rebuild — and the rebuild treats the last-fetched ledger as
|
||||
* authoritative and cancels any pending drag commit, so a rename
|
||||
* visibly rewound the whole graph whenever display had drifted
|
||||
* (recent drag, mid-anneal, physics re-anneal). This channel touches
|
||||
* ONLY label text: the region meta the draw pass reads plus the
|
||||
* data-side cluster record (so the next geometry rebuild —
|
||||
* visibility/settings/size — keeps the fresh label). Nodes, the
|
||||
* sim, positions, and the pending commit are never touched. */
|
||||
setClusterLabels(labels: ReadonlyMap<string, string | null>): void {
|
||||
const data = this.data;
|
||||
if (!data) return;
|
||||
const settings = this.settings;
|
||||
// No settings yet → nothing is drawn; patch the data side only and
|
||||
// let the next build derive the wrap (same guard the build uses).
|
||||
if (!settings) {
|
||||
applyClusterLabels(data.clusters, [], labels, REGION_LABEL_MAX_WIDTH, REGION_LABEL_MAX_LINES, () => 0);
|
||||
return;
|
||||
}
|
||||
const ctx = this.ctx;
|
||||
const prevFont = ctx.font;
|
||||
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize}px ${data.fontFamily}`;
|
||||
const changed = applyClusterLabels(
|
||||
data.clusters,
|
||||
this.regionMeta,
|
||||
labels,
|
||||
REGION_LABEL_MAX_WIDTH,
|
||||
REGION_LABEL_MAX_LINES,
|
||||
(s) => ctx.measureText(s).width,
|
||||
);
|
||||
ctx.font = prevFont;
|
||||
if (changed) this.markDirty();
|
||||
}
|
||||
|
||||
setVisible(visible: ReadonlySet<string> | null): void {
|
||||
this.visible = visible;
|
||||
this.geoVersion++; // member visibility changes hulls + paths
|
||||
@@ -1229,6 +1303,14 @@ export class GraphCanvasRenderer {
|
||||
},
|
||||
});
|
||||
this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode]));
|
||||
// Hygiene (2026-09-23 fix wave): a fresh sim starts at alpha 1 and
|
||||
// never decays while stopped — so the first post-rebuild drag would
|
||||
// re-anneal the whole graph at full strength (alphaTarget(0.3)
|
||||
// glides DOWN from 1 instead of gently up from rest). Initialize at
|
||||
// the settle floor so first-drag behavior matches steady-state
|
||||
// drags. setPhysicsTuning still rehearses its explicit alpha(1)
|
||||
// when a full re-anneal IS the point.
|
||||
this.sim.alpha(SIM_SETTLE_ALPHA);
|
||||
}
|
||||
|
||||
/** Rasterize cluster regions into the low-res world-space bitmap.
|
||||
@@ -1296,6 +1378,7 @@ export class GraphCanvasRenderer {
|
||||
}
|
||||
if (members > 0) {
|
||||
this.regionMeta.push({
|
||||
id: cluster.id,
|
||||
label: cluster.label || null,
|
||||
cx: sumX / members,
|
||||
cy: sumY / members,
|
||||
|
||||
Reference in New Issue
Block a user