Skip to content

Commit 8df15b8

Browse files
DGINXREALclaude
andcommitted
docs: add clicks-by-country choropleth map design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e3a9d67 commit 8df15b8

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Clicks-by-Country Choropleth Map — Design
2+
3+
**Date:** 2026-06-25
4+
**Status:** Approved (pending spec review)
5+
6+
## Summary
7+
8+
Add a world choropleth map to the statistics UI showing which countries generate
9+
the most clicks. Countries are shaded by click volume (darker = more clicks). The
10+
map appears on **both** statistics surfaces — the project-level dashboard and the
11+
per-link stats page — via a single shared React component.
12+
13+
## Problem & Context
14+
15+
Clicks already record geo data. `GeoIpService` resolves a country name, city,
16+
and ISO `country_code` from the visitor's (hashed) IP on every click. Today the
17+
statistics table persists only the country **name** string (e.g. `"Germany"`),
18+
and both stats pages render a "top countries" table/bar breakdown. There is no
19+
geographic visualization, and the persisted name is locale/spelling-sensitive,
20+
which makes it a poor key for coloring map regions.
21+
22+
A choropleth keys on **ISO 3166-1 alpha-2 codes**. We already compute the code
23+
on every click — we just discard it before saving.
24+
25+
## Architecture
26+
27+
```
28+
Click → RedirectController → GeoIpService.lookup() ──┐ (country, city, country_code)
29+
▼
30+
RecordClickStatisticJob
31+
persists country, city, country_code ◄── NEW: country_code
32+
▼
33+
statistics table (+ country_code col)
34+
▼
35+
StatisticsAggregator.breakdownByCountryCode() ◄── NEW
36+
▼
37+
StatisticsController / UrlController → Inertia prop `clicksByCountry` ◄── NEW
38+
▼
39+
<WorldMap data={clicksByCountry} /> ◄── NEW shared component
40+
▼
41+
Statistics/Index.tsx & Links/Show.tsx (both reuse it)
42+
```
43+
44+
## Data Layer
45+
46+
### Migration
47+
- Add nullable `country_code` (`CHAR(2)`, ISO 3166-1 alpha-2) to the `statistics`
48+
table, indexed alongside the existing geo columns.
49+
- Forward-only migration, matching the existing migration style in this repo.
50+
51+
### Write path
52+
- `RecordClickStatisticJob` already receives the full geo array
53+
(`['country' => 'Germany', 'country_code' => 'DE', 'city' => ..., ...]`) from
54+
`GeoIpService`. It currently saves `country`/`city` and drops the code.
55+
- Change: also persist `country_code` from the geo array. No changes to
56+
`GeoIpService` or `RedirectController` — the code is already computed and passed.
57+
58+
### Backfill
59+
- A one-time backfill (migration step or artisan command) maps existing distinct
60+
`country` name values → ISO codes using a static name→code lookup table, updating
61+
historical rows.
62+
- Rows whose name is not in the table remain `country_code = null`. They are simply
63+
absent from the map; they remain fully counted in totals and in the existing
64+
name-based table breakdown.
65+
66+
### Aggregation
67+
- Add `breakdownByCountryCode()` to `StatisticsAggregator`, grouping by
68+
`country_code` and returning rows shaped:
69+
```php
70+
{ country_code: "DE", country: "Germany", count: 234 }
71+
```
72+
(`country` is the representative name for tooltip/label display.)
73+
- Rows with `country_code = null` are excluded from this aggregation.
74+
- The window/filtering honors the same `$since` / `days` range already passed to
75+
the other aggregations.
76+
77+
### Controller integration
78+
- `StatisticsController` (project level) and `UrlController::show` (per link) each
79+
pass a new Inertia prop `clicksByCountry` produced by `breakdownByCountryCode()`,
80+
using the same range window already in use.
81+
- The existing `topCountries` prop and its table breakdown are **untouched**.
82+
83+
## Frontend Rendering
84+
85+
### Library choice
86+
Render with a **custom SVG component built on `d3-geo` + `topojson-client`** —
87+
not `react-simple-maps`. Rationale:
88+
- `react-simple-maps`' published peer deps lag React 19 (this project is on
89+
React 19) and it has been lightly maintained — install friction and future churn.
90+
- A custom component matches the existing hand-rolled SVG charts (e.g. the raw-SVG
91+
`BarChart` in `Statistics/Index.tsx`), needs no API key, carries no React-version
92+
risk, and gives full control over theming and tooltips.
93+
- A tile map (Mapbox/Leaflet) is overkill: API key/tiles, heavy bundle, and a
94+
pannable globe is the wrong tool for a static "top countries" summary.
95+
96+
**Dependencies added:** `d3-geo`, `topojson-client`, and a small bundled world
97+
TopoJSON (~100KB) served as a static asset.
98+
99+
### Component
100+
- `resources/js/Components/WorldMap.tsx`
101+
- Props: `{ data: { country_code: string; country: string; count: number }[] }`
102+
- Behavior:
103+
- Load the world TopoJSON once; project with `geoNaturalEarth1()`.
104+
- Render one `<path>` per country.
105+
- Fill via a **quantized** color scale (≈5 buckets) from a light neutral to the
106+
brand color, computed from the max count in the current dataset.
107+
- Countries with zero clicks render in a faint "no data" fill.
108+
- Hover tooltip shows country name + click count.
109+
- Small legend indicating the scale.
110+
- Reused verbatim on both the project and per-link pages.
111+
112+
## Page Integration
113+
114+
- Drop `<WorldMap data={clicksByCountry} />` into both `Statistics/Index.tsx` and
115+
`Links/Show.tsx`, placed **above** the existing country table breakdown — the map
116+
gives the overview, the table gives exact numbers.
117+
- Both surfaces continue to respect the existing `days` range filter, since
118+
`clicksByCountry` is computed with the same window as the other aggregations.
119+
120+
## States & Error Handling
121+
122+
- **Empty** (no geo-tagged clicks yet): render the world map greyed out with a small
123+
"No location data yet" caption rather than hiding it, so layout stays stable.
124+
- **Unknown / null codes**: rows with `null` country_code are silently excluded from
125+
the map but remain counted in totals and the name-based table.
126+
- **TopoJSON load**: bundled as a static asset and imported, so there is no network
127+
failure path. If the import ever fails, the component renders the caption fallback
128+
and never throws.
129+
130+
## Testing
131+
132+
### Backend
133+
- `breakdownByCountryCode()` groups by code and returns the joined representative name.
134+
- `RecordClickStatisticJob` persists `country_code` from the geo array.
135+
- Backfill maps known names to codes and leaves unknown names `null`.
136+
- Follow existing statistics test patterns (use `route()`, not bare paths, per the
137+
project's test-host gotcha).
138+
139+
### Frontend
140+
- Gate is `npm run build` (TypeScript check), since `npm run lint` is broken in this
141+
project.
142+
- Verify the component renders with sample data and renders the empty state.
143+
- No new test runner introduced.
144+
145+
## Out of Scope (YAGNI)
146+
147+
- City-level or lat/lng marker maps.
148+
- Pan/zoom or interactive drill-down.
149+
- Sub-national (region/state) choropleth.
150+
- Changing or removing the existing country table/bar breakdown.

0 commit comments

Comments
 (0)