diff --git a/src/lib/components/atlas/atlas-chart.svelte b/src/lib/components/atlas/atlas-chart.svelte new file mode 100644 index 0000000..3569045 --- /dev/null +++ b/src/lib/components/atlas/atlas-chart.svelte @@ -0,0 +1,204 @@ + + +
+ {#if width} + { + pointer = event.pointerType; + if (pointer === "mouse") return; + const hit = nearest(event); + armed = hit !== null && hit === active; + active = hit; + }} + onpointermove={(event) => { + if (event.pointerType === "mouse") active = nearest(event); + }} + onpointerleave={(event) => { + if (event.pointerType === "mouse") active = null; + }} + onclick={() => { + if (current && (pointer === "mouse" || armed)) onopen(current.entry); + }} + {onkeydown} + > + + + + {#each dim as p (p.entry.id)} + + {/each} + {#each lit as p (p.entry.id)} + + {/each} + {#if current} + + + {/if} + + + + {#if current} +
+ isLog(axis as Axis)) + .map((axis) => ({ + label: axis === "height" ? "Height" : "Weight", + value: `${axisValue(current.entry, axis as Axis)} ${axis === "height" ? "m" : "kg"}`, + }))} + /> +
+ {/if} + {:else} +
+ {/if} +
+ + diff --git a/src/lib/components/dex/dex-wall.svelte b/src/lib/components/dex/dex-wall.svelte index 7f3f6ca..dd7e236 100644 --- a/src/lib/components/dex/dex-wall.svelte +++ b/src/lib/components/dex/dex-wall.svelte @@ -1,13 +1,11 @@ + +
+
+ + {#key entry.id} + + {/key} + +
+

{dexNumber(entry.speciesId)}

+

{entry.name}

+
+ {#each entry.types as type (type)} + + {/each} +
+
+
+
+ +
+ {#each STAT_KEYS as key, i (key)} +
+
{STAT_LABELS[key].short}
+
{entry.stats[i]}
+
+ {/each} +
+
+
+
+
Base stat total
+
{statTotal(entry)}
+
+ {#each notes as note (note.label)} +
+
{note.label}
+
{note.value}
+
+ {/each} +
+
diff --git a/src/lib/dex/atlas.test.ts b/src/lib/dex/atlas.test.ts new file mode 100644 index 0000000..61a86b3 --- /dev/null +++ b/src/lib/dex/atlas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; +import { axisValue, isAxis, makeScale } from "./atlas.js"; + +describe("axisValue", () => { + const entry = { + stats: [78, 84, 78, 109, 85, 100] as [number, number, number, number, number, number], + height: 17, + weight: 905, + }; + it("reads stats, totals and metric sizes", () => { + expect(axisValue(entry, "special-attack")).toBe(109); + expect(axisValue(entry, "total")).toBe(534); + expect(axisValue(entry, "height")).toBe(1.7); + expect(axisValue(entry, "weight")).toBe(90.5); + }); +}); + +describe("makeScale", () => { + it("uses round linear ticks from zero", () => { + const scale = makeScale([10, 180, 255], "attack"); + expect(scale.domain).toEqual([0, 300]); + expect(scale.ticks).toEqual([0, 50, 100, 150, 200, 250, 300]); + expect(scale.at(150)).toBe(0.5); + }); + + it("uses powers of ten on a log scale", () => { + const scale = makeScale([0.1, 1.7, 14.5], "height"); + expect(scale.ticks).toEqual([0.1, 1, 10, 20]); + expect(scale.at(20)).toBe(1); + expect(makeScale([0.1, 999.9], "weight").ticks).toEqual([0.1, 1, 10, 100, 1000]); + }); +}); + +describe("isAxis", () => { + it("accepts known axes only", () => { + expect(isAxis("weight")).toBe(true); + expect(isAxis("cuteness")).toBe(false); + }); +}); diff --git a/src/lib/dex/atlas.ts b/src/lib/dex/atlas.ts new file mode 100644 index 0000000..2228757 --- /dev/null +++ b/src/lib/dex/atlas.ts @@ -0,0 +1,81 @@ +import { STAT_KEYS, STAT_LABELS, type StatKey } from "$lib/pokemon/types.js"; +import type { DexListEntry } from "./list.js"; + +export const AXES = [...STAT_KEYS, "total", "height", "weight"] as const; +export type Axis = (typeof AXES)[number]; + +export function isAxis(value: string | null): value is Axis { + return (AXES as readonly (string | null)[]).includes(value); +} + +export const AXIS_LABELS: Record = { + ...(Object.fromEntries(STAT_KEYS.map((key) => [key, STAT_LABELS[key].long])) as Record< + StatKey, + string + >), + total: "Base stat total", + height: "Height (m)", + weight: "Weight (kg)", +}; + +/** Height and weight span four orders of magnitude, so they plot on a log scale. */ +export function isLog(axis: Axis): boolean { + return axis === "height" || axis === "weight"; +} + +export function axisValue(entry: Pick, axis: Axis) { + if (axis === "total") return entry.stats.reduce((sum, s) => sum + s, 0); + if (axis === "height") return entry.height / 10; + if (axis === "weight") return entry.weight / 10; + return entry.stats[STAT_KEYS.indexOf(axis)]; +} + +export interface Scale { + domain: [number, number]; + ticks: number[]; + /** Map a value to 0–1 along the axis. */ + at: (value: number) => number; +} + +function niceStep(span: number, target: number): number { + const raw = span / target; + const power = 10 ** Math.floor(Math.log10(raw)); + const unit = raw / power; + return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 5 ? 5 : 10) * power; +} + +/** Smallest 1, 2 or 5 × 10ⁿ at or above a value, so log axes end near the data. */ +function logCeil(value: number): number { + const power = 10 ** Math.floor(Math.log10(value)); + const step = [1, 2, 5, 10].find((m) => m * power >= value * 0.999) ?? 10; + return Number((step * power).toPrecision(1)); +} + +export function makeScale(values: readonly number[], axis: Axis, target = 6): Scale { + const max = Math.max(...values, 1); + if (isLog(axis)) { + const min = Math.max(Math.min(...values), 0.1); + const low = 10 ** Math.floor(Math.log10(min)); + const high = logCeil(max); + const ticks: number[] = []; + for (let t = low; t < high * 0.999; t *= 10) ticks.push(Number(t.toPrecision(1))); + ticks.push(high); + const span = Math.log10(high) - Math.log10(low); + return { + domain: [low, high], + ticks, + at: (v) => (Math.log10(Math.max(v, low)) - Math.log10(low)) / span, + }; + } + const step = niceStep(max, target); + const high = Math.ceil(max / step) * step; + const ticks = Array.from({ length: Math.round(high / step) + 1 }, (_, i) => i * step); + return { domain: [0, high], ticks, at: (v) => v / high }; +} + +export const PRESETS: { x: Axis; y: Axis; label: string }[] = [ + { x: "attack", y: "special-attack", label: "Physical or special" }, + { x: "defense", y: "special-defense", label: "Which defense" }, + { x: "speed", y: "total", label: "Fast and strong" }, + { x: "height", y: "weight", label: "Size" }, +]; diff --git a/src/lib/nav.ts b/src/lib/nav.ts index a365ea0..dc3a0c2 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -5,6 +5,7 @@ export interface NavItem { export const navItems: NavItem[] = [ { href: "/", label: "Pokédex" }, + { href: "/atlas", label: "Atlas" }, { href: "/moves", label: "Moves" }, { href: "/abilities", label: "Abilities" }, { href: "/types", label: "Types" }, diff --git a/src/routes/atlas/+page.server.ts b/src/routes/atlas/+page.server.ts new file mode 100644 index 0000000..cb4e7e1 --- /dev/null +++ b/src/routes/atlas/+page.server.ts @@ -0,0 +1,13 @@ +import { isAxis } from "$lib/dex/atlas.js"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = ({ url }) => { + const x = url.searchParams.get("x"); + const y = url.searchParams.get("y"); + return { + x: isAxis(x) ? x : ("attack" as const), + y: isAxis(y) ? y : ("special-attack" as const), + q: url.searchParams.get("q") ?? "", + forms: url.searchParams.get("forms") === "1", + }; +}; diff --git a/src/routes/atlas/+page.svelte b/src/routes/atlas/+page.svelte new file mode 100644 index 0000000..535f045 --- /dev/null +++ b/src/routes/atlas/+page.svelte @@ -0,0 +1,172 @@ + + + + Atlas | Dexa + + + +
+

Atlas

+

+ Every Pokémon plotted by two measures. Search to highlight a group and see where it sits; hover a point to identify it, click to open it. +

+ +
+ { + const next = PRESETS.find((p) => p.label === label); + if (next) setAxes(next.x, next.y); + }} + aria-label="Views" + class="flex-wrap" + > + {#each PRESETS as p (p.label)} + {p.label} + {/each} + + +
+ {#each [{ axis: "x", value: x, label: "Across" }, { axis: "y", value: y, label: "Up" }] as control (control.axis)} + (control.axis === "x" ? setAxes(value as Axis, y) : setAxes(x, value as Axis))} + > + + {control.label}: + {AXIS_LABELS[control.value]} + + + {#each AXES as axis (axis)} + + {/each} + + + {/each} +
+ +
+ { + forms = checked; + sync(); + }} + /> + +
+
+ +
+ + { + q = event.currentTarget.value; + sync(); + }} + placeholder="Highlight with a name or filters like type:dragon or role:wall" + aria-label="Highlight Pokémon" + autocomplete="off" + spellcheck={false} + class="h-11 rounded-lg bg-card pl-11 text-base md:text-base" + /> +
+
+
+ ((q = next), sync())} /> +
+ ((q = `${q.trim()} ${example}`.trim()), sync())} /> +
+ +
+
+

+ {#if !all} + Loading every Pokémon… + {:else if matches} + {count.format(matches.size)} of {count.format(plotted.length)} highlighted + {:else} + {count.format(plotted.length)} Pokémon + {/if} +

+ + {matches ? "List these in the Pokédex" : "See them as a table"} + +
+ {#if all} + goto(`/pokemon/${entry.slug}`)} /> + {:else} +
+ {/if} +
+