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>getstransform→ 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 withtransform.apply([d.x, d.y])and setcx/cy(dots) andx/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-rescaledchart:circle.attr("transform", d => translate(transform.apply(d)))— we usecx/cy+x/yinstead of per-elementtransformbecause 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):
- 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”). - Labels follow their dots. Label
x/ymust be recomputed in the same zoom handler as the dots, using the sametransform.apply([d.x, d.y])result + the same label offset logic (e.g.x + r + 3for right-side labels). - Static layer stays static. The
gcontaining 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.) - Preset zoom buttons + search “fly-to” must also use the rescale math. Buttons (
zoomTo) and searchpick()callsvg.transition().call(zoom.transform, ...)— with the new handler this still works because the handler readsev.transform; but the targettranslate/scalevalues must be recomputed for rescale semantics so the final view actually centers the intended region (see Task 2 Step 4 formula). - Wheel/pinch still zooms via the same
d3.zoom()behavior (touch-action: noneCSS already in place). We are changing ONLY the zoom handler’s body. - Hover/tooltip unchanged. Dots still have mouseover/mousemove/mouseout handlers bound to the circle elements;
d.x/d.ystay 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 staticg; they will be positioned per-dot). - Lines 205–210: zoom block — REPLACE (below).
- Lines 211–224:
zoomTo()+ button handlers — UPDATE preset math + keepsvg.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”.
- Line 116–117:
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 mainStep 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/cyfromcur.apply([d.x, d.y]) - label
x=p[0] + rScale(Math.max(d.price,1)) + 4,y=p[1] + 3
- dot
- Lines 192–200 (
zoomTo) — update presets totx = W/2 - k*cx; ty = H/2 - k*cywith data-space centers per button (cheap ≈ low price low seats; mid ≈ 1k; ent ≈ high price; multiseat ≈ seats>10). - Line 223
pick()— confirmW/2 - k*t.xalready present (it is; keep). - Hint copy line 70 — update wording.
- Lines 122–123:
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()recomputingcx/cyfromcur.apply([d.dx, d.dy]). This view has no labels (dashboard mini-view) — dots only. - Keep
touch-actionscoping (already#viz0, #viz1, #viz2only).
Step 2: viz1 — compliance×price scatter (lines 235–276)
- Base positions already exist:
d.sx/d.sy(data space) andd.x/d.y(collision-separated, lines 254–261). Used.x/d.yas 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) andd.x/d.y(separated, lines 300–307). Used.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.mdand/orlinkedin-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.mdor add a new dated plan doc.
Tests / Validation (each task)
| Check | Command / action | Expected |
|---|---|---|
| Data integrity | node linkedin-market/scripts/validate-tools-data.js | VALIDATION PASSED: 68 tools... |
| Equivalence | node linkedin-market/scripts/check-equivalence.js | PASS (data unchanged) |
| Zoom behavior | Browser smoke: open deployed page, wheel-zoom into a cluster | Adjacent dots physically separate; labels follow; axes/background static; no console errors |
| Presets | Click each zoom button | View jumps to intended region with dots separated |
| Search | Type a tool name, Enter | Dot + label fly to center, highlighted |
| Deploy | wrangler deploy + curl | All URLs 200 |
Risks, tradeoffs, open questions
Risks:
- R1 (the historical failure): mutating
d.x/d.yduring zoom. Mitigation: zoom handler reads only; base positions frozen after collision separation (they already are — theseparate()IIFEs run once and never run again; nothing else writesd.x/d.ytoday). - R2: Performance — recomputing 68
transform.applycalls per zoom frame is trivial (68 dots, no measurable cost). Labels double it. Non-issue at this scale. - R3:
zoomTopreset centers may need visual tuning (mycx/cyguesses). Mitigation: invariant formula is correct; values adjustable; verify visually per button. - R4: Search fly-to (
pick) centers ont.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.