Rescale-Zoom Fix for All LinkedIn-Market Charts (d3 zoom-svg-rescaled pattern)

Status: Tasks 1–3 DONE (deployed + verified + pushed). Task 4 pending Rahul’s decision. Task 5 pending.

For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.

Goal: Replace the current “group-transform” zoom (g.attr("transform", ev.transform), the @d3/zoom pattern that scales a block — two close dots stay glued together) with the rescale pattern (@d3/zoom-svg-rescaled: each dot’s position is recomputed per zoom event via transform.apply(d)), so that zooming in genuinely separates close dots. Apply to ONE chart first for Rahul’s verification, then roll out to all charts only after his go-ahead.

Architecture: Keep the existing SVG, D3 v7, collision separation, always-visible labels, search, and preset buttons. Change ONLY the zoom mechanics:

  • Old: one <g> gets transform → everything (dots AND axes/background) scales as a rigid block → dots never separate.
  • New: dots + labels keep base positions (d.x, d.y) in data space; on every zoom event, recompute each dot’s pixel position with transform.apply([d.x, d.y]) and set cx/cy (dots) and x/y (labels) directly. Axes/background/grid stay in a separate, non-zooming layer (they are reference frames — they should NOT zoom).
  • This is exactly the Observable @d3/zoom-svg-rescaled chart: circle.attr("transform", d => translate(transform.apply(d))) — we use cx/cy + x/y instead of per-element transform because we also have labels, and it keeps hover hit-testing on the visible dot.

Tech Stack: D3 v7 (already loaded via cdn.jsdelivr.net), SVG, vanilla JS. No new dependencies. No Module-3 data changes. No AI-credit cost (M4-only change).

Key correctness requirements (read before any task):

  1. Base positions must persist. Each tool keeps d.x/d.y = its collision-separated base position (already computed today). The zoom handler must NOT mutate them — only read them. This is the single most important rule; mutating base positions is the bug that made previous attempts fail (“same outcome”).
  2. Labels follow their dots. Label x/y must be recomputed in the same zoom handler as the dots, using the same transform.apply([d.x, d.y]) result + the same label offset logic (e.g. x + r + 3 for right-side labels).
  3. Static layer stays static. The g containing axes, gridlines, zone rectangles, stage/evolution labels must never be re-transformed. Only dots + labels move. (Wardley: the stage/evo columns and axis labels are the reference frame; the GrowReach-slot rect/text are ALSO static frame — they mark territory, not data.)
  4. Preset zoom buttons + search “fly-to” must also use the rescale math. Buttons (zoomTo) and search pick() call svg.transition().call(zoom.transform, ...) — with the new handler this still works because the handler reads ev.transform; but the target translate/scale values must be recomputed for rescale semantics so the final view actually centers the intended region (see Task 2 Step 4 formula).
  5. Wheel/pinch still zooms via the same d3.zoom() behavior (touch-action: none CSS already in place). We are changing ONLY the zoom handler’s body.
  6. Hover/tooltip unchanged. Dots still have mouseover/mousemove/mouseout handlers bound to the circle elements; d.x/d.y stay valid for tooltip content.

Task 1: Pilot — convert the standalone Wardley map (v2/wardley-map.htm) to rescale zoom

Objective: Rahul’s verify-on-1-graph-first. After this task, https://hermesvps.pages.dev/linkedin-market/v2/wardley-map.htm must show dots physically separating when zooming in.

Files:

  • Modify: linkedin-market/v2/wardley-map.htm
    • Line 116–117: const svg = ... / const g = svg.append("g") — KEEP as-is (static layer).
    • Line 178: const dotG = g.append("g"), labelG = g.append("g") — KEEP (children of static g; they will be positioned per-dot).
    • Lines 205–210: zoom block — REPLACE (below).
    • Lines 211–224: zoomTo() + button handlers — UPDATE preset math + keep svg.transition().call(zoom.transform, ...).
    • Lines 241–250: pick() search fly-to — UPDATE to rescale math.
    • Hint text line 65: update copy to mention “zoom separates overlapping dots”.

Step 1: Add a zoomed() function that rescales dots + labels

Replace lines 205–210 (the current zoom block):

// ---- rescale-zoom (d3 zoom-svg-rescaled pattern): recompute each dot's position ----
let cur = d3.zoomIdentity;
function zoomed(ev) {
  cur = ev.transform;
  dotG.selectAll("circle").attr("cx", d => cur.apply([d.x, d.y])[0])
    .attr("cy", d => cur.apply([d.x, d.y])[1]);
  labelG.selectAll("text")
    .attr("x", d => { const p = cur.apply([d.x, d.y]); return d.evo === 3 ? p[0] - rScale(Math.max(d.mid,1)) - 3 : p[0] + rScale(Math.max(d.mid,1)) + 3; })
    .attr("y", d => cur.apply([d.x, d.y])[1] + 3);
  d3.selectAll(".bar .btn").classed("active", false);
}
const zoom = d3.zoom().scaleExtent([0.6, 14]).on("zoom", zoomed);
svg.call(zoom);

Step 2: Run a browser smoke check

Open linkedin-market/v2/wardley-map.htm in the browser (file:// or a quick python3 -m http.server), wheel-zoom in on a cluster (e.g. ENGAGE × PRODUCT, where 7+ comment tools overlap). Verify:

  • Dots spread apart as scale increases (distance between two adjacent dots grows).
  • Labels follow their dots.
  • Axes/stage labels/background do NOT zoom.
  • No console errors (check browser_console).

Expected: separation behavior matches @d3/zoom-svg-rescaled (dots move apart), NOT @d3/zoom (dots stay glued).

Step 3: Update preset-zoom buttons (zoomTo) to rescale math

Replace the body of zoomTo() (lines 211–220). New math: target center (cx, cy) in DATA space, k scale; final transform must satisfy apply([cx,cy]) = [W/2, H/2]tx = W/2 - k*cx, ty = H/2 - k*cy:

function zoomTo(sel) {
  let cx = 0.5, cy = 3, k = 1;   // data-space center + scale (defaults: whole map)
  if (sel === "all")       { k = 1; cx = 1.55; cy = 3; }
  else if (sel === "compound") { cx = 1.2; cy = 1;   k = 2.6; }
  else if (sel === "engage")   { cx = 1.2; cy = 3;   k = 2.6; }
  else if (sel === "find")     { cx = 1.2; cy = 5;   k = 2.6; }
  else if (sel === "genesis")  { cx = 0.4; cy = 3;   k = 2.2; }
  else if (sel === "product")  { cx = 2.6; cy = 3;   k = 2.2; }
  const tx = W/2 - k*cx, ty = H/2 - k*cy;
  svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
}

(Derivation: stage rows run y=1..5 (FIND=5 bottom … COMPOUND=1 top, per y scale domain [0.6,5.4]), evolution x runs 0..3 → map center ≈ x 1.55, y 3. Buttons: compound≈stage1, engage≈stage3, find≈stage5; genesis≈x0.4, product≈x2.6. Tune the cx/cy values at implementation time against the actual layout — the important invariant is tx = W/2 - k*cx; ty = H/2 - k*cy.)

Step 4: Update search fly-to (pick())

Replace line 242’s tx/ty computation:

function pick(t) {
  const k = 3.5, tx = W/2 - k*t.x, ty = H/2 - k*t.y;   // ← ALREADY correct rescale math — keep
  svg.transition().duration(600).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
  ...
}

Verify the existing pick() already uses W/2 - k*t.x — if yes, it needs NO change (it already flies to the dot’s base position, which under rescale zoom is the dot’s true data position). Only confirm it uses t.x/t.y (base), not a transformed value.

Step 5: Update hint copy

Line 65: change scroll = zoom (dots spread/compress)scroll = zoom (dots physically separate) · drag = pan.

Step 6: Validate + deploy

cd /opt/data/hermes-repo
node linkedin-market/scripts/validate-tools-data.js     # expect: VALIDATION PASSED
# deploy (standard workflow):
cd web && rm -rf public && npx quartz build -d /opt/data/hermes-repo
export CLOUDFLARE_API_TOKEN="$(cat /opt/data/.secrets/cf-token)"; export CLOUDFLARE_ACCOUNT_ID="c2b4dc9267e98f852121fdfc905bdbf4"
npx --yes wrangler@latest pages deploy public --project-name=hermesvps --branch=main
curl -sL -o /dev/null -w "%{http_code}" https://hermesvps.pages.dev/linkedin-market/v2/wardley-map.htm   # expect 200 (allow ~5s propagation)

Step 7: Commit (only linkedin-market/)

cd /opt/data/hermes-repo
git add linkedin-market/v2/wardley-map.htm
git commit -m "feat: rescale-zoom on wardley map — dots separate on zoom (d3 zoom-svg-rescaled pattern)"
git push origin main

Step 8: STOP — hand to Rahul for verification

Give Rahul ONE link: https://hermesvps.pages.dev/linkedin-market/v2/wardley-map.htm. Ask him to zoom into ENGAGE/PRODUCT (the cluster of comment tools) and confirm two dots near each other separate when zooming in. Do NOT proceed to Task 2+ until he says it works.


Task 2: (AFTER GO-AHEAD) Convert the agency price×seats map (v2/agency-price-seats-map.htm)

Objective: Same rescale treatment on the dedicated agency pricing page.

Files:

  • Modify: linkedin-market/v2/agency-price-seats-map.htm
    • Lines 122–123: const svg / const g — KEEP as static layer.
    • Line 151: const dotG = g.append("g"), labelG = g.append("g") — KEEP.
    • Lines 186–191: zoom block — REPLACE with the same zoomed() pattern as Task 1 Step 1, except labels sit to the RIGHT of dots (d.x + rScale(...) + 4):
      • dot cx/cy from cur.apply([d.x, d.y])
      • label x = p[0] + rScale(Math.max(d.price,1)) + 4, y = p[1] + 3
    • Lines 192–200 (zoomTo) — update presets to tx = W/2 - k*cx; ty = H/2 - k*cy with data-space centers per button (cheap ≈ low price low seats; mid ≈ 1k; ent ≈ high price; multiseat ≈ seats>10).
    • Line 223 pick() — confirm W/2 - k*t.x already present (it is; keep).
    • Hint copy line 70 — update wording.

Validation: browser smoke check (dots separate on zoom; labels follow; axes static; console clean), validator, deploy, commit (feat: rescale-zoom on agency price×seats map).


Task 3: (AFTER GO-AHEAD) Convert the dashboard (v2/12-market-viz.htm) — all three zoomable views

Objective: Apply rescale zoom to the dashboard’s 3 zoomable views. Views viz3 (heatmap), viz4 (price bands), viz5 (dead table) are NOT zoomable — leave untouched (they’re already correct, page-scrollable).

Files:

  • Modify: linkedin-market/v2/12-market-viz.htm

Step 1: viz0 — dashboard Wardley view (lines 200–233)

This view differs from the standalone: dots are built inline (lines 219–231) with x(d.evo + jitter) / y(d.stage + jitter) and NO collision separation, NO labels, NO base-position fields. Convert to rescale:

  • Before the zoom handler, snapshot base positions: add t.dx = x(d.evo + (Math.random()-.5)*0.16); t.dy = y(d.stage + (Math.random()-.5)*0.22); when creating dots, OR restructure to store on datum.
  • Replace the zoom handler (line 272 pattern) with zoomed() recomputing cx/cy from cur.apply([d.dx, d.dy]). This view has no labels (dashboard mini-view) — dots only.
  • Keep touch-action scoping (already #viz0, #viz1, #viz2 only).

Step 2: viz1 — compliance×price scatter (lines 235–276)

  • Base positions already exist: d.sx/d.sy (data space) and d.x/d.y (collision-separated, lines 254–261). Use d.x/d.y as the rescale base (they are the separated positions).
  • Lines 272–273: replace with:
let cur1 = d3.zoomIdentity;
const zoom = d3.zoom().scaleExtent([0.6, 14]).on("zoom", (ev) => {
  cur1 = ev.transform;
  dots.attr("cx", d => cur1.apply([d.x, d.y])[0])
      .attr("cy", d => cur1.apply([d.x, d.y])[1]);
});
el.call(zoom);
  • No labels in this view (confirm; the labelG-style labels were only on the standalone pages). If labels exist, move them the same way.

Step 3: viz2 — positioning quadrant (lines 278–320)

  • Base positions: d.qx/d.qy (data) and d.x/d.y (separated, lines 300–307). Use d.x/d.y.
  • Lines 316–317: same replacement as Step 2 with its own cur2.

Step 4: Validate + deploy + commit

Same as Task 1 Step 6–7. Commit: feat: rescale-zoom on dashboard scatter + quadrant + wardley views.


Task 4: (AFTER GO-AHEAD) Update old hardcoded charts (comparison set)

Objective: The old non-dynamic charts (linkedin-market/wardley-map.htm, linkedin-market/agency-price-seats-map.htm, linkedin-market/12-market-viz-d3.htm) are the “old hardcoded versions (kept for comparison)”. They are static (no zoom). Decision needed from Rahul — see Open Questions Q1. Default (if no answer): leave them untouched — they’re comparison artifacts; the zoom fix applies to the live v2 pages. If Rahul wants them updated, apply the same rescale zoom where a zoom exists, or note they have no zoom so nothing to fix.


Task 5: (AFTER GO-AHEAD) Regression checks + docs

Objective: Make sure nothing else regressed and the knowledge is captured.

  • Run node linkedin-market/scripts/check-equivalence.js — v2 charts vs old charts data equivalence. Note: equivalence is about DATA, not zoom behavior; document that the check should still pass (same tools-data.js source).
  • Update linkedin-market/14-module-3-data-repo.md and/or linkedin-market/plans/ docs: note the zoom pattern change (rescale vs group-transform) and that ALL charts going forward MUST use rescale zoom (Rahul’s standing requirement).
  • Optionally update the plan file linkedin-market/plans/2026-08-17-module3-data-repo-module4-dynamic-charts.md or add a new dated plan doc.

Tests / Validation (each task)

CheckCommand / actionExpected
Data integritynode linkedin-market/scripts/validate-tools-data.jsVALIDATION PASSED: 68 tools...
Equivalencenode linkedin-market/scripts/check-equivalence.jsPASS (data unchanged)
Zoom behaviorBrowser smoke: open deployed page, wheel-zoom into a clusterAdjacent dots physically separate; labels follow; axes/background static; no console errors
PresetsClick each zoom buttonView jumps to intended region with dots separated
SearchType a tool name, EnterDot + label fly to center, highlighted
Deploywrangler deploy + curlAll URLs 200

Risks, tradeoffs, open questions

Risks:

  • R1 (the historical failure): mutating d.x/d.y during zoom. Mitigation: zoom handler reads only; base positions frozen after collision separation (they already are — the separate() IIFEs run once and never run again; nothing else writes d.x/d.y today).
  • R2: Performance — recomputing 68 transform.apply calls per zoom frame is trivial (68 dots, no measurable cost). Labels double it. Non-issue at this scale.
  • R3: zoomTo preset centers may need visual tuning (my cx/cy guesses). Mitigation: invariant formula is correct; values adjustable; verify visually per button.
  • R4: Search fly-to (pick) centers on t.x/t.y — with rescale, that’s correct (base position). If a tool was nudged far by collision, the fly-to still lands on the actual dot. Good.
  • R5: Dashboard viz0 lacks collision separation + labels (inline build). Converting it introduces a slight behavior difference vs standalone (dots may overlap more at default view). Acceptable for a mini-view; note it.

Tradeoffs:

  • Rescale zoom recomputes positions per event instead of a single <g> transform — marginally more JS per frame, but enables the separation Rahul wants and keeps labels correct. At 68 dots this is the right call.
  • Axes/background no longer zoom with content. This is intentional (reference frame stays readable); if Rahul wants axes to zoom too, that’s a follow-up — but note that scaling axes while separating dots is exactly what the old pattern did wrong.

Open questions (ask Rahul at the Task 1 handoff, do not block Task 1):

  • Q1: The 3 OLD hardcoded charts (linkedin-market/wardley-map.htm, agency-price-seats-map.htm, 12-market-viz-d3.htm) — keep as static comparison artifacts, or update them too? (Default: leave untouched.)
  • Q2: Should preset zoom buttons stay on the Wardley/agency pages (they currently are)? They remain functional under rescale. (Default: keep.)
  • Q3: Confirm the dashboard viz0 (mini Wardley) should get the same treatment even though it has no labels/collision (Default: yes, for consistency.)

Execution handoff

After Task 1 is verified by Rahul, execute Tasks 2–5 with subagent-driven-development: one fresh subagent per task, each with this plan + the completed prior task’s diff as context, spec-compliance review then code-quality review per task.