Skip to content

Commit 7a50485

Browse files
Improve spider plot ordering and hover tooltips
1 parent 77547b1 commit 7a50485

5 files changed

Lines changed: 223 additions & 20 deletions

File tree

src/data-model.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export const AXIS_DESCRIPTIONS = {
4949
"Market Cap": "Domestic listed-company market cap, normalized to 2000 where reliable.",
5050
};
5151

52+
export const SPIDER_AXIS_ORDER = [
53+
"GDP",
54+
"Market Cap",
55+
"ETF Price",
56+
"GDP per Capita",
57+
];
58+
5259
export const FALLBACK_METRIC_METADATA = {
5360
GDP: {
5461
unit: "current US$",
@@ -231,6 +238,16 @@ export function getCountryLabel(iso3) {
231238
return metadata.shortName;
232239
}
233240

241+
/**
242+
* Returns country flag or empty string for non-country profiles.
243+
*
244+
* @param {string} id Country ISO-3 or region id.
245+
* @returns {string} Flag emoji when available.
246+
*/
247+
export function getProfileFlag(id) {
248+
return COUNTRY_METADATA[id]?.flag ?? "";
249+
}
250+
234251
/**
235252
* Computes average regional growth multiples for a given axis and year.
236253
*

src/main.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as d3 from "d3";
88
import {
99
DEFAULT_SELECTION,
1010
REGION_ORDER,
11+
SPIDER_AXIS_ORDER,
1112
buildComparisonProfiles,
1213
getCountryLabel,
1314
getYearIndex,
@@ -69,7 +70,7 @@ async function main() {
6970
yearSlider.addEventListener("input", () => updateRangeProgress(yearSlider));
7071

7172
const updateMap = createIndicatorMap(mapContainer, data, toggleCountry, toggleRegion);
72-
const spiderAxes = [...data.axes];
73+
const spiderAxes = SPIDER_AXIS_ORDER.filter((axis) => data.axes.includes(axis));
7374
const spiderChart = createSpiderChart(spiderContainer, data, {
7475
axes: spiderAxes,
7576
});

src/spider-plot.js

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export function createSpiderPlot(container, options) {
3232
const root = svg
3333
.append("g")
3434
.attr("transform", `translate(${svgSize / 2},${svgSize / 2})`);
35+
const tooltip = d3
36+
.select(container)
37+
.append("div")
38+
.attr("class", "spider-tooltip")
39+
.style("display", "none");
3540

3641
const gridLayer = root.append("g").attr("class", "spider-grid");
3742
const axisLayer = root.append("g").attr("class", "spider-axes");
@@ -56,9 +61,13 @@ export function createSpiderPlot(container, options) {
5661
}
5762

5863
function update(profiles) {
59-
const renderProfiles = profiles.map((profile) =>
60-
buildRenderableProfile(profile, axes, rScale, maxValue),
61-
);
64+
const renderProfiles = profiles.map((profile) => {
65+
const profileWithBadge = {
66+
...profile,
67+
badge: options.getProfileBadge ? options.getProfileBadge(profile) : profile.label,
68+
};
69+
return buildRenderableProfile(profileWithBadge, axes, rScale, maxValue);
70+
});
6271
renderLegend(legendLayer, renderProfiles);
6372

6473
const groups = dataLayer
@@ -74,6 +83,7 @@ export function createSpiderPlot(container, options) {
7483

7584
merged.each(function renderProfile(profile) {
7685
const group = d3.select(this);
86+
group.attr("data-profile-id", profile.id);
7787

7888
group
7989
.select("polygon")
@@ -99,7 +109,18 @@ export function createSpiderPlot(container, options) {
99109
.attr("stroke-dasharray", (segment) => (segment.dotted ? "6,5" : null))
100110
.attr("class", (segment) =>
101111
segment.dotted ? "spider-segment spider-segment--dotted" : "spider-segment",
102-
);
112+
)
113+
.on("pointerenter", function handleEnter(event, segment) {
114+
setHoveredProfile(profile.id);
115+
showTooltip(
116+
event,
117+
options.segmentTooltipFormatter
118+
? options.segmentTooltipFormatter(profile, segment)
119+
: defaultSegmentTooltip(profile, segment),
120+
);
121+
})
122+
.on("pointermove", moveTooltip)
123+
.on("pointerleave", clearHoverState);
103124

104125
group
105126
.select("g.spider-dots")
@@ -115,22 +136,76 @@ export function createSpiderPlot(container, options) {
115136
.attr("class", (point) =>
116137
point.extrapolated ? "spider-dot spider-dot--extrapolated" : "spider-dot",
117138
)
118-
.each(function addTooltip(point) {
119-
d3.select(this).selectAll("title").remove();
120-
d3.select(this)
121-
.append("title")
122-
.text(
123-
options.pointTooltipFormatter
124-
? options.pointTooltipFormatter(profile, point)
125-
: `${profile.label} ${point.axis}: ${point.value ?? "n/a"}`,
126-
);
127-
});
139+
.on("pointerenter", function handleEnter(event, point) {
140+
if (point.value === null) return;
141+
setHoveredProfile(profile.id);
142+
showTooltip(
143+
event,
144+
options.pointTooltipFormatter
145+
? options.pointTooltipFormatter(profile, point)
146+
: defaultPointTooltip(profile, point),
147+
);
148+
})
149+
.on("pointermove", moveTooltip)
150+
.on("pointerleave", clearHoverState);
128151
});
129152

153+
legendLayer
154+
.selectAll("g")
155+
.on("pointerenter", (_, profile) => setHoveredProfile(profile.id))
156+
.on("pointerleave", clearHoverState);
157+
130158
groups.exit().remove();
131159
}
132160

133161
return { update, setAxes };
162+
163+
function setHoveredProfile(profileId) {
164+
dataLayer
165+
.selectAll("g.spider-profile")
166+
.classed("is-active", (profile) => profile.id === profileId)
167+
.classed("is-muted", (profile) => profile.id !== profileId);
168+
169+
legendLayer
170+
.selectAll("g")
171+
.classed("is-active", (profile) => profile.id === profileId)
172+
.classed("is-muted", (profile) => profile.id !== profileId);
173+
}
174+
175+
function clearHoverState() {
176+
dataLayer
177+
.selectAll("g.spider-profile")
178+
.classed("is-active", false)
179+
.classed("is-muted", false);
180+
legendLayer
181+
.selectAll("g")
182+
.classed("is-active", false)
183+
.classed("is-muted", false);
184+
tooltip.style("display", "none");
185+
}
186+
187+
function showTooltip(event, html) {
188+
tooltip.style("display", "block").html(html);
189+
moveTooltip(event);
190+
}
191+
192+
function moveTooltip(event) {
193+
const bounds = container.getBoundingClientRect();
194+
const tooltipNode = tooltip.node();
195+
const tooltipWidth = tooltipNode?.offsetWidth ?? 0;
196+
const tooltipHeight = tooltipNode?.offsetHeight ?? 0;
197+
const left = Math.min(
198+
event.clientX - bounds.left + 16,
199+
bounds.width - tooltipWidth - 12,
200+
);
201+
const top = Math.min(
202+
event.clientY - bounds.top + 16,
203+
bounds.height - tooltipHeight - 12,
204+
);
205+
tooltip
206+
.style("left", `${Math.max(12, left)}px`)
207+
.style("top", `${Math.max(12, top)}px`);
208+
}
134209
}
135210

136211
function normalizeAxes(axes) {
@@ -144,6 +219,7 @@ function renderLegend(layer, profiles) {
144219
.selectAll("g")
145220
.data(profiles, (profile) => profile.id)
146221
.join("g")
222+
.attr("class", "spider-profile-legend__item")
147223
.attr("transform", (_, index) => {
148224
const column = index % 2;
149225
const row = Math.floor(index / 2);
@@ -264,21 +340,37 @@ function buildRenderableProfile(profile, axes, rScale, maxValue) {
264340

265341
segments.push({
266342
id: `${current.axis}->${next.axis}`,
343+
profileId: profile.id,
267344
x1: current.x,
268345
y1: current.y,
269346
x2: next.x,
270347
y2: next.y,
271348
dotted: current.extrapolated || next.extrapolated,
349+
fromAxis: current.axis,
350+
fromLabel: current.label,
351+
fromValue: current.value,
352+
toAxis: next.axis,
353+
toLabel: next.label,
354+
toValue: next.value,
272355
});
273356
}
274357

275358
return {
276359
id: profile.id,
277360
label: profile.label,
361+
badge: profile.badge ?? profile.label,
278362
color: profile.color,
279363
points,
280364
segments,
281365
isClosed: points.every((point) => point.value !== null),
282366
polygonPoints: points.map((point) => [point.x, point.y].join(",")).join(" "),
283367
};
284368
}
369+
370+
function defaultPointTooltip(profile, point) {
371+
return `${profile.badge} ${point.label}: ${point.value ?? "n/a"}`;
372+
}
373+
374+
function defaultSegmentTooltip(profile, segment) {
375+
return `${profile.badge} ${segment.fromLabel} -> ${segment.toLabel}`;
376+
}

src/spider.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { formatMultiple } from "./data-model.js";
1+
import { formatMultiple, getProfileFlag } from "./data-model.js";
22
import { createSpiderPlot } from "./spider-plot.js";
33

44
const MAX_VALUE = 8;
@@ -17,9 +17,26 @@ export function createSpiderChart(container, data, options = {}) {
1717
axes,
1818
maxValue: MAX_VALUE,
1919
ariaLabel: "Spider chart comparing selected profiles",
20+
getProfileBadge(profile) {
21+
const flag = getProfileFlag(profile.id);
22+
return flag ? `${flag} ${profile.label}` : profile.label;
23+
},
2024
pointTooltipFormatter(profile, point) {
21-
const suffix = point.extrapolated ? " (estimated)" : "";
22-
return `${profile.label} ${point.label}: ${formatMultiple(point.value)}${suffix}`;
25+
const suffix = point.extrapolated ? '<div class="spider-tooltip__meta">Estimated</div>' : "";
26+
return `
27+
<div class="spider-tooltip__title">${getProfileTooltipLabel(profile)}</div>
28+
<div class="spider-tooltip__metric">${point.label}</div>
29+
<div class="spider-tooltip__value">${formatMultiple(point.value)}</div>
30+
${suffix}
31+
`;
32+
},
33+
segmentTooltipFormatter(profile, segment) {
34+
const suffix = segment.dotted ? '<div class="spider-tooltip__meta">Contains estimated point</div>' : "";
35+
return `
36+
<div class="spider-tooltip__title">${getProfileTooltipLabel(profile)}</div>
37+
<div class="spider-tooltip__metric">${segment.fromLabel} -> ${segment.toLabel}</div>
38+
${suffix}
39+
`;
2340
},
2441
});
2542

@@ -30,3 +47,8 @@ export function createSpiderChart(container, data, options = {}) {
3047
},
3148
};
3249
}
50+
51+
function getProfileTooltipLabel(profile) {
52+
const flag = getProfileFlag(profile.id);
53+
return flag ? `${flag} ${profile.label}` : profile.label;
54+
}

src/style.css

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@ button:active {
762762

763763
.chart {
764764
overflow: hidden;
765+
position: relative;
765766
}
766767

767768
.chart svg {
@@ -786,33 +787,103 @@ button:active {
786787

787788
.spider-area {
788789
stroke-linejoin: round;
789-
transition: opacity 200ms var(--ease-emphasized);
790+
transition:
791+
opacity 200ms var(--ease-emphasized),
792+
fill-opacity 200ms var(--ease-emphasized);
790793
}
791794

792795
.spider-segment {
793-
transition: opacity 200ms var(--ease-emphasized);
796+
transition:
797+
opacity 200ms var(--ease-emphasized),
798+
stroke-width 200ms var(--ease-emphasized);
794799
}
795800

796801
.spider-segment--dotted {
797802
opacity: 0.88;
798803
}
799804

805+
.spider-dot {
806+
transition:
807+
opacity 200ms var(--ease-emphasized),
808+
r 200ms var(--ease-emphasized),
809+
stroke-width 200ms var(--ease-emphasized);
810+
}
811+
800812
.spider-dot--extrapolated {
801813
opacity: 0.88;
802814
}
803815

816+
.spider-profile.is-muted .spider-area,
817+
.spider-profile.is-muted .spider-segment,
818+
.spider-profile.is-muted .spider-dot {
819+
opacity: 0.18;
820+
}
821+
822+
.spider-profile.is-active .spider-area {
823+
fill-opacity: 0.28;
824+
}
825+
826+
.spider-profile.is-active .spider-segment {
827+
stroke-width: 3.1;
828+
}
829+
830+
.spider-profile.is-active .spider-dot {
831+
r: 5.6;
832+
stroke-width: 1.6;
833+
}
834+
804835
.spider-country-label {
805836
font-size: 12px;
806837
font-weight: 700;
807838
letter-spacing: -0.005em;
808839
}
809840

841+
.spider-profile-legend__item {
842+
transition: opacity 200ms var(--ease-emphasized);
843+
}
844+
845+
.spider-profile-legend__item.is-muted {
846+
opacity: 0.36;
847+
}
848+
810849
.spider-profile-legend text {
811850
font-size: 12.5px;
812851
font-weight: 700;
813852
letter-spacing: -0.005em;
814853
}
815854

855+
.spider-tooltip {
856+
position: absolute;
857+
z-index: 20;
858+
max-width: 240px;
859+
padding: 10px 12px;
860+
border: 1px solid rgba(255, 255, 255, 0.12);
861+
border-radius: 10px;
862+
background: rgba(8, 12, 20, 0.94);
863+
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.35);
864+
pointer-events: none;
865+
color: var(--text-primary);
866+
}
867+
868+
.spider-tooltip__title {
869+
font-size: 13px;
870+
font-weight: 700;
871+
}
872+
873+
.spider-tooltip__metric,
874+
.spider-tooltip__meta {
875+
margin-top: 4px;
876+
color: var(--text-secondary);
877+
font-size: 12px;
878+
}
879+
880+
.spider-tooltip__value {
881+
margin-top: 6px;
882+
font-size: 18px;
883+
font-weight: 700;
884+
line-height: 1;
885+
}
886+
816887
/* ── Evolution chart styling ─────────────────────────────────────────── */
817888

818889
.evolution-axes path,

0 commit comments

Comments
 (0)