NGU-Web/src/data/mapGrid.js

174 lines
8.5 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
MAP GRID
Pure layout. Where each tile sits, what it's called, and how to
work out which tile a chapter belongs to. None of this is in the
database because none of it changes — Rhode Island will not be
moving, and no admin form should offer to move it.
What IS in the database is which regions cover which areas, and
how much of each. That arrives as region_areas rows whose
area_code matches a key in AREAS below. A code with no match
here simply doesn't paint, which is how Africa and the UK exist
as regions with no tile.
═══════════════════════════════════════════════════════════════ */
export const GRID_COLS = 13;
export const GRID_ROWS = 7;
/* ── States ────────────────────────────────────────────────────
[column, row], both 1-based. A tile grid rather than true
geography: every state reads at the same size, it stays
legible on a phone, and there's no map library to load.
───────────────────────────────────────────────────────────── */
const STATE_GRID = {
AK: [1, 1], ME: [13, 1],
WA: [2, 2], ID: [3, 2], MT: [4, 2], ND: [5, 2], MN: [6, 2], WI: [7, 2],
MI: [8, 3], NY: [10, 2], VT: [11, 2], NH: [12, 2],
OR: [2, 3], NV: [3, 4], WY: [4, 3], SD: [5, 3], IA: [6, 3], IL: [7, 3],
IN: [7, 4], OH: [8, 4], PA: [9, 2], NJ: [10, 3], MA: [11, 3],
CA: [2, 4], UT: [3, 3], CO: [4, 4], NE: [5, 4], MO: [6, 4], KY: [7, 5],
WV: [9, 3], VA: [9, 4], MD: [10, 5], DE: [10, 4], CT: [11, 4],
AZ: [3, 5], NM: [4, 5], KS: [5, 5], AR: [6, 5], TN: [8, 5], NC: [10, 6],
DC: [9, 5], RI: [12, 3],
OK: [5, 6], LA: [6, 6], MS: [7, 6], AL: [8, 6], SC: [9, 6],
HI: [1, 7], TX: [5, 7], GA: [9, 7], FL: [10, 7],
};
/* ── Bands ─────────────────────────────────────────────────────
Wide areas that aren't states. A band is just a tile with a
span, which keeps the renderer from needing a second code path.
───────────────────────────────────────────────────────────── */
const BAND_GRID = {
CANADA: [3, 1, 8], // col, row, span
};
export const AREA_NAMES = {
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
DC: "District of Columbia", FL: "Florida", GA: "Georgia", HI: "Hawai'i",
ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island",
SC: "South Carolina", SD: "South Dakota", TN: "Tennessee", TX: "Texas",
UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
CANADA: "Canada",
};
/* ── One list the renderer walks ───────────────────────────────
States and bands unified, so drawing the map is a single map()
over AREAS rather than two loops with different shapes.
───────────────────────────────────────────────────────────── */
export const AREAS = Object.freeze([
...Object.entries(STATE_GRID).map(([code, [col, row]]) => ({
code,
name: AREA_NAMES[code] ?? code,
col,
row,
span: 1,
isState: true,
})),
...Object.entries(BAND_GRID).map(([code, [col, row, span]]) => ({
code,
name: AREA_NAMES[code] ?? code,
col,
row,
span,
isState: false,
})),
]);
export const AREA_BY_CODE = Object.fromEntries(AREAS.map((a) => [a.code, a]));
/* ── Which tile a chapter sits on ──────────────────────────────
The database stores a real address — state_code for US, and a
country code otherwise — rather than a tile name. This is the
one place that translates between the two, so adding a Mexico
band later means a line here and nothing in SQL.
Returns null for anything with no tile, which covers virtual
chapters and any country not drawn.
───────────────────────────────────────────────────────────── */
const COUNTRY_AREA = {
CA: "CANADA", // ISO country code, not California
};
export function areaForChapter(chapter) {
if (!chapter) return null;
if (chapter.is_online) return null;
if (chapter.country === "US") {
return AREA_BY_CODE[chapter.state_code] ? chapter.state_code : null;
}
return COUNTRY_AREA[chapter.country] ?? null;
}
/* ── Slices per tile ───────────────────────────────────────────
Turns region_areas rows into what the SVG needs: for each
tile, the regions painting it and the fraction each takes.
A region with share 1 and no edge fills the tile. A shared
tile has one row per region, each declaring its own slice, so
California is two entries of 0.5 rather than a primary plus a
remainder — no arithmetic, and the renderer doesn't need to
know which region "really" owns it.
regionAreas [{ region_id, area_code, share, edge, note }]
regions [{ id, name, color, ... }]
───────────────────────────────────────────────────────────── */
export function buildAreaSlices(regionAreas = [], regions = []) {
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
const byArea = {};
for (const row of regionAreas) {
const area = AREA_BY_CODE[row.area_code];
const region = regionById[row.region_id];
if (!area || !region) continue; // untiled region, or unknown code
(byArea[row.area_code] ??= []).push({
regionId: region.id,
name: region.name,
color: region.color,
share: row.share ?? 1,
edge: row.edge ?? null,
note: row.note ?? null,
});
}
// Full-tile slice first, so a partial slice paints over it.
for (const slices of Object.values(byArea)) {
slices.sort((a, b) => b.share - a.share);
}
return byArea;
}
/* ── Chapter counts per tile ───────────────────────────────────
{ WA: 2, MO: 1, CANADA: 1 }
───────────────────────────────────────────────────────────── */
export function countChaptersByArea(chapters = []) {
const counts = {};
for (const chapter of chapters) {
const code = areaForChapter(chapter);
if (code) counts[code] = (counts[code] ?? 0) + 1;
}
return counts;
}
/* ── "AK, ID, MT, OR, UT (Salt Lake City area), WA" ────────────
Replaces statesLabel. Note the annotation now comes straight
from the row rather than being looked up in a splits table and
branched on whether this region is the primary.
───────────────────────────────────────────────────────────── */
export function areasLabel(regionId, regionAreas = []) {
return regionAreas
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
.sort()
.join(", ");
}