This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
@@ -0,0 +1,9 @@
|
||||
# Port exposed by the BE compose stack on the host. Caddy reverse-proxies /api/* here.
|
||||
HOST_PORT=3000
|
||||
|
||||
# Host path bind-mounted into the container at /app/data.
|
||||
# Must contain catalogue.db, atlases/, flags/.
|
||||
DATA_DIR=./data
|
||||
|
||||
# Ingest pipeline only (bun run ingest). Not read by the deployed server.
|
||||
NUMISTA_API_KEY=
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
public
|
||||
.next
|
||||
@@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'plugin:@next/next/recommended',
|
||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
||||
'@untile/eslint-config-typescript-react'
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@tanstack/query'],
|
||||
rules: {
|
||||
'id-length': 'off',
|
||||
'id-match': ['error', '^_$|^_?[a-zA-Z][_a-zA-Z0-9]*$|^[A-Z][_A-Z0-9]+[A-Z0-9]$'],
|
||||
'no-underscore-dangle': 'off',
|
||||
'react/no-unknown-property': 'off',
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
};
|
||||
@@ -4,31 +4,56 @@ on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
group: deploy-coins
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:latest
|
||||
volumes:
|
||||
- /var/www/coins:/deploy-fe
|
||||
- /opt/coins:/deploy
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: apk add --no-cache git nodejs
|
||||
run: apk add --no-cache git bash curl nodejs
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Copy source to deploy directory
|
||||
- name: Install workspace dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
run: bun run build
|
||||
|
||||
- name: Deploy frontend
|
||||
run: |
|
||||
rm -rf /deploy-fe/*
|
||||
cp -r dist/* /deploy-fe/
|
||||
|
||||
- name: Copy source for backend build
|
||||
run: |
|
||||
rm -rf /deploy/build
|
||||
mkdir -p /deploy/build
|
||||
cp -r . /deploy/build
|
||||
|
||||
- name: Create .env file
|
||||
working-directory: /deploy/build
|
||||
run: |
|
||||
echo "HOST_PORT=${{ vars.HOST_PORT }}" > .env
|
||||
echo "PHOTOS_DIR=/opt/coins/photos" >> .env
|
||||
cat > .env << 'EOF'
|
||||
HOST_PORT=${{ vars.HOST_PORT }}
|
||||
DATA_DIR=/opt/coins/data
|
||||
NUMISTA_API_KEY=${{ secrets.NUMISTA_API_KEY }}
|
||||
EOF
|
||||
|
||||
- name: Build and deploy
|
||||
- name: Build and deploy backend
|
||||
working-directory: /deploy/build
|
||||
run: |
|
||||
docker compose build
|
||||
|
||||
+6
-45
@@ -1,46 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/bin/python
|
||||
/bin/marigold
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.directory
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# assets
|
||||
public/photos
|
||||
public/photos-original
|
||||
public/done
|
||||
tmp
|
||||
|
||||
# sqlite cache
|
||||
main.db-shm
|
||||
main.db-wal
|
||||
.bun
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
public/models
|
||||
+10
-18
@@ -1,28 +1,20 @@
|
||||
FROM oven/bun:latest AS base
|
||||
FROM oven/bun:1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y imagemagick && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM deps AS builder
|
||||
COPY . .
|
||||
RUN bunx prisma generate
|
||||
RUN --mount=type=bind,from=photos,source=.,target=/app/public/photos \
|
||||
bun run build
|
||||
COPY tsconfig.json ./
|
||||
COPY src/server ./src/server
|
||||
COPY src/db ./src/db
|
||||
COPY src/ingest ./src/ingest
|
||||
COPY src/filter-types.ts src/atlas-layout.ts ./src/
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
ENV COINS_DATA_DIR=/app/data
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "server.js"]
|
||||
CMD ["bun", "src/server/index.ts"]
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Coins — Project Plan
|
||||
|
||||
A personal-collection coin viewer: data ingested from Numista, rendered as spinning 3D objects in a small static-but-data-driven web app. Self-hosted on a home server.
|
||||
|
||||
## Vision
|
||||
|
||||
Three rooms:
|
||||
|
||||
1. **`/` — Home.** A country grid (alphabetised, with counts) over an ambient "hail" of falling coins. Pure entry point. Each row links to `/showcase` and `/pile` filtered by that country.
|
||||
2. **`/showcase` — Showcase.** One coin at a time on a turntable with proper product-photography lighting. Carousel-style nav. Info panel with country flag, denomination, dimensions, weight, and a link out to Numista.
|
||||
3. **`/pile` — Pile.** All coins matching the filter dropped onto a circular floor, settling into a heap under physics. The collection visualised as a physical pile.
|
||||
|
||||
The point isn't a generic Numista viewer — it's a curated, opinionated tribute.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
|
||||
- **Vite** in MPA mode, one HTML entry per page (`index.html`, `showcase.html`, `pile.html`), each mounting its own React tree.
|
||||
- **React 19 + TypeScript** (StrictMode on every entry).
|
||||
- **react-three/fiber 9 + drei 10 + rapier 2** for 3D and physics.
|
||||
- **Tailwind v4** for styling, via `@tailwindcss/vite`.
|
||||
- **nuqs** for URL-driven state (current coin, spin toggle, country filter).
|
||||
- **react-aria-components** for accessible UI primitives (buttons today; filter sidebar later).
|
||||
- **Bun** as runtime and package manager.
|
||||
- **`bun:sqlite`** for the catalogue.
|
||||
- **sharp + node-vibrant** for image processing at ingest time.
|
||||
- **lru-cache** behind a `use()`-based data hook. No mutations from the frontend and no revalidation needs, so a request library (SWR / React-Query) would be over-tooled; the LRU bounds the cache as filter-URL permutations multiply. (Today the cache effectively holds one entry — `/api/pieces` — because the server doesn't yet honour query parameters; see the TODO in cross-cutting work.)
|
||||
|
||||
### Runtime layout
|
||||
|
||||
```
|
||||
┌-----------------┐ /api/* ┌------------------┐
|
||||
│ Caddy │ ------------------> │ Bun.serve │
|
||||
│ (TLS + static) │ │ + bun:sqlite │
|
||||
│ │ <-- /atlases/* │ │
|
||||
│ │ /flags/* │ │
|
||||
│ │ /* (HTML/JS) │ │
|
||||
└-----------------┘ served direct └------------------┘
|
||||
from disk
|
||||
```
|
||||
|
||||
- Static built assets (HTML/JS/CSS, atlases, flags) served by Caddy from disk.
|
||||
- Only `/api/*` reaches Bun.
|
||||
- Catalogue + media live on a non-git-tracked volume (`/var/lib/coins/`), bind-mounted into the container in prod and into `./data/` in dev.
|
||||
|
||||
### Naming
|
||||
|
||||
The umbrella term for a Numista-catalogued item is **piece** — coin, banknote, or exonumia (the three `category` values). DB row type is `Piece`; API returns `{ pieces, facets }` from `/api/pieces`; the dispatcher component `<Piece>` routes to `<Coin>` or `<Banknote>` by category. "Coin" stays reserved for the genuinely coin-specific case — `<Coin>` and its cylinder/extruded geometry, the coin-only `/pile`, the coin-only `<Hail>`. The SQLite table is still `coins` (not renamed — would cost a migration and the abstraction leaks either way). Numista's external API is the only place "type" survives (`fetchNumistaType`, `/types/{id}`) because that's their vocabulary.
|
||||
|
||||
### Source layout
|
||||
|
||||
```
|
||||
src/
|
||||
pages/ one page tsx + entry tsx per route
|
||||
home.tsx, home-entry.tsx
|
||||
showcase.tsx, showcase-entry.tsx
|
||||
pile.tsx, pile-entry.tsx
|
||||
components/
|
||||
piece/ piece renderer (umbrella over coin / banknote / exonumia)
|
||||
index.tsx <Piece> — dispatches Coin vs Banknote by category
|
||||
coin/ <Coin> + cylinder/extruded geometry, outlines, variants
|
||||
banknote/ <Banknote> + thin-box geometry
|
||||
hail/ home-page falling-coins background
|
||||
pile/ /pile scene + per-coin physics body
|
||||
loading.tsx
|
||||
db/ bun:sqlite wrapper, schema, queries
|
||||
server/ Bun.serve entry (HTTP API)
|
||||
ingest/ CLI + Numista/Wikidata/images/normalize/atlas
|
||||
atlas-layout.ts shared atlas geometry constants (UV layout)
|
||||
api.ts frontend fetchers (LRU + use())
|
||||
```
|
||||
|
||||
## Data pipeline
|
||||
|
||||
1. **`bun run ingest add <numista-id> [+count]`** — fetches one type from the Numista API (`/types/{id}`) with `Numista-API-Key`. Subcommands: `add` (idempotent, additive on count), `remove`, `set-count`, `refetch`, `rebake`. Issuers are auto-resolved: Numista → Wikidata (P41) → Commons SVG. On miss, a warning prints with the path to drop a fallback flag manually.
|
||||
2. **Normalisation.** Numista's shape string ("Round with a round hole", "Spanish flower", "Square", ...) is classified into a `shape_variant` (one of the values listed in the Geometry section below) plus a `shape_params` JSON object with sensible defaults. The CLI then runs an interactive prompt (`@clack/prompts`): for variants that take parameters it shows each one pre-filled with the derived default — Enter to accept, type to override; for shapes that didn't map to a known variant it presents a picker covering all 9 variants. Ctrl+C keeps defaults, never abandons the in-progress ingest. The renderer reads variant + params directly from the row.
|
||||
3. **Image download.** Obverse / reverse / edge are fetched with `User-Agent` + `Referer: https://en.numista.com/` headers and cached as the original JPEGs in `data/sources/{id}-{side}.{ext}`.
|
||||
4. **Atlas bake.** sharp composes one webp per coin per resolution: `data/atlases/{id}-256.webp` and `{id}-1024.webp`. Layout: `[obverse][reverse]` on top, edge strip below (16-px or 64-px tall).
|
||||
- `cropRim` heuristic finds the actual rim band in the edge photo (greyscale + corner-pixel background luminance + longest contiguous row-deviation run, bails on extremes).
|
||||
- Edge texture: center 50 % of the rim cropped, tiled 3× across the strip, so the cylinder side wraps cleanly without using the foreshortened ends of the source photo.
|
||||
- All intermediate sharp steps are `.png()` (lossless); only the final atlas is lossy WebP.
|
||||
- `node-vibrant` extracts the dominant colour to use as placeholder fill.
|
||||
5. **DB upsert.** Normalised + colour + sources → `coins` row; issuer info → `issuers` row with FK from `coins.issuer_code`. Mass comes from Numista's `weight` directly (in grams).
|
||||
|
||||
## Geometry & rendering
|
||||
|
||||
### Atlas → UV mapping
|
||||
|
||||
- One image, three regions: obverse (top-left 50 % × top fraction), reverse (top-right 50 % × top fraction), edge strip (full width × bottom ~12 %).
|
||||
- `buildCoinGeometry(radius, thickness, atlasSize)` produces a `CylinderGeometry` whose UVs are rewritten to sample those three regions. Caps are rotated 90° within their tile so picture-up lands on the cylinder's local −Z axis (which maps to world +Y after scene orientation).
|
||||
- `useCoinMaterial(piece, atlas)` returns the `MeshStandardMaterial` (metalness=1, roughness=0.45) sampled from the atlas; `coinDimensions(piece)` returns `{ radius, thickness }` in metres (Numista's mm divided by 1000 at the boundary). The geometry is built inline per-coin via `buildCoinGeometry` or `buildExtrudedCoinGeometry`, depending on variant. Banknotes go through their own materials + `buildBanknoteGeometry`.
|
||||
|
||||
### Scene units
|
||||
|
||||
- **`/showcase`, `/`** are in metres (1 unit = 1 m). A coin is 0.023 units wide.
|
||||
- **`/pile`** is in millimetres (1 unit = 1 mm). A coin is 23 units wide. This was a deliberate switch to keep Rapier in its numerically-comfortable range — its hard-coded epsilons and tolerances scale poorly to sub-cm worlds. The shared `buildCoinGeometry` is unit-agnostic; the caller decides.
|
||||
|
||||
### Lighting model
|
||||
|
||||
- Showcase and pile use a **rect-area-light** rig (warm key + cool fill + optional rim/bounce). Rect area lights show up *as a shape* on a metal surface — that's the studio-softbox look. Pure directional/point lights show up as tiny dots and look wrong on metals.
|
||||
- No HDRI / environment maps. We tried; presets had too much directional variance across rotation. Static rect-area lights are predictable.
|
||||
- Showcase lights live in a `LightRig` that copies the camera quaternion each frame, so the rig orbits with the view (so the user always feels they're "manipulating a coin under fixed lights").
|
||||
|
||||
## Page-by-page status
|
||||
|
||||
### `/` — Home ✅
|
||||
|
||||
- Country grid: flag + name + per-category counts (e.g. `200 coins (1 unique) · 3 banknotes`).
|
||||
- Per-row links to `/showcase?countries=<iso>` and `/pile?countries=<iso>` (the pile link works once `/pile` is done).
|
||||
- `<Hail>` background: ten falling coins (banknotes excluded — pool fetched with `categories: ['coin']`, since hail renders `<Coin>` directly rather than going through `<Piece>`), weighted sampling by `coin.count`, respawning above the camera frustum so we never see a coin spawning mid-screen.
|
||||
|
||||
### `/showcase` — Showcase ✅
|
||||
|
||||
- Single-coin turntable with three permanently-mounted slots (current / prev / next, all atlases pre-loaded for zero nav latency).
|
||||
- Carousel transitions: ease-out cubic, ~400 ms, transitions blocked while one is in flight; both keyboard nav and react-aria `<Button>` controls.
|
||||
- Info panel: flag + country (small caps) + count badge + title + face value · year range + ⌀ diameter · ↕ thickness · weight + Numista link.
|
||||
- nuqs URL state: `?id`, `?spin`, plus the full filter set (see Filters below).
|
||||
- Spin axis depends on `coin.orientation`: medal coins spin on Y (vertical), coin-orientation coins spin on X (horizontal page-flip). Atlas pre-rotates the reverse 180° for coin orientation so both faces read upright.
|
||||
|
||||
### `/pile` — Pile ✅
|
||||
|
||||
- Rapier physics in mm units, weighted gravity, native `CylinderCollider` with a 0.1 mm `contactSkin` (small enough to be visually imperceptible, large enough to keep face-on-face contact manifolds stable), real density from Numista weight, soft CCD for cheap tunnelling protection.
|
||||
- Camera, floor, `ContactShadows` all in mm.
|
||||
- **Lighting: four stage-light fixtures.** Each is a spotlight (`castShadow`, `penumbra={1}`, `angle=π/3`, mm-scale `intensity` with `decay=2`) co-located with a small GLTF model of a barn-doored theatre lamp (`/models/stage-light.gltf`, scaled ×16 to match mm). The model has the lid geometry baked into its `Base` primitive, with `castShadow=true` — so the lids physically block part of the spotlight's cone and project rectangular gobo shadows onto the floor around the pile. The GLTF group gets a random `lookAt` per fixture so each one's lids cast a different pattern. A thin black cylindrical pole runs from each fixture down through the floor to make the rigging feel anchored. Fixtures sit at the four diagonal corners (±150 mm in x and z, 250 mm above the floor) — close enough that the overlap of the four cones is roughly pile-sized rather than dwarfing it. `<ambientLight intensity={0.05}>` only — barely enough to keep the cone shadows from going pitch black.
|
||||
- Drop heights above visible frustum so coins enter the frame from the top.
|
||||
- **Custom sleep, not Rapier's.** `<CustomSleep>` parks each coin individually after 0.3 s below per-body thresholds (3 mm/s linear, 3 rad/s angular). After a coin parks once, if Rapier auto-wakes it from incidental neighbour-contact and it's still below threshold, we re-park immediately. Rapier's native island sleep can't do this — it needs *every* body in the contact island below threshold for a sustained 2 s, and in a 200-coin pile that's one big island with constant micro-jitter from a handful of wobblers, so the timer never completes. Real motion (a new coin dropped on the pile) is preserved: the woken body's velocity exceeds threshold, the "settled" mark is cleared, and the normal 0.3 s timer runs. Rollers (linvel ≈ radius × angvel ≫ 3 mm/s) are kept awake by the linear gate, so an upright-landing coin gets to roll out before settling.
|
||||
- Other load-bearing settings: `<Physics lengthUnit={24}>` (sets Rapier's normalized tolerances to coin-scale), `<Physics numSolverIterations={8} numInternalPgsIterations={2} contactNaturalFrequency={60}>` and per-coin `additionalSolverIterations={8}` for converged stiff contacts, per-coin `angularDamping={2}` (gentle — rollers visibly roll for a beat after landing; not aggressive enough to kill wobblers alone, which is `<CustomSleep>`'s job), `softCcdPrediction` per coin instead of full `ccd`. The native `CylinderCollider` (vs convex-hull prism) is critical — prisms generated frame-to-frame contact-point chatter that no solver work could resolve.
|
||||
|
||||
### Filters ✅
|
||||
|
||||
- Six dimensions: countries (multi-select), currencies (multi-select), face value (stepped slider with explicit "Any" at position 0), type (coins/banknotes/exonumia — inverted checkbox group), edition (standard/special — inverted), year range (two-thumb slider).
|
||||
- **Pivoted facets**: each filter's available options are computed against all *other* active filters. Selecting Portugal narrows currencies to {Euro}, but the country list stays full so you can keep switching.
|
||||
- **URL is the contract end-to-end.** nuqs reads/writes the query string client-side; the server parses the same params into a SQL `WHERE`; the LRU on `fetchPieces` keys per full URL, so each distinct filter permutation is its own cache entry.
|
||||
- Server returns `{ pieces, facets }` in one response. Facets are 7 SQL queries (one main + one per faceted dimension, each WHERE excluding its own filter).
|
||||
- Sidebar UI: `react-aria-components` (Button, Checkbox/Group, ListBox+Popover for multi-select, Slider). Plain animated `<aside>` instead of `Modal` so the 3D canvas stays interactive while the drawer is open. Top bar with Home / cross-route nav that preserves the query string.
|
||||
- Stale URL values (e.g. a country no longer in the facet after another filter narrows it) are hidden from the UI but not proactively cleared — they fall out the next time the user interacts with that field.
|
||||
|
||||
## Cross-cutting work still to do
|
||||
|
||||
In rough priority order:
|
||||
|
||||
1. **Geometry coverage.** ✅
|
||||
- **Shape variants on the row.** Each `coins` row carries `shape_variant TEXT` (one of `round`, `polygon`, `holed-round`, `holed-polygon`, `notched-round`, `sine-round`, `irregular`; banknotes use `rectangular`) and `shape_params TEXT` (JSON whose schema depends on the variant; `NULL` for round/irregular/rectangular). The `polygon` variant is the generalised N-gon with knobs for `corner_roundness`, `edge_roundness`, and `phase`, subsuming the previous `rounded-square` / `rounded-diamond` variants (which were dropped — phase=0 gives a diamond, phase=0.5 an axis-aligned square; phase is in units of one segment, parity-independent, so phase=1 ≡ phase=0). `sine-round` also takes a `phase` param (units of one peak period). SQLite enforces both via CHECKs: `json_valid(shape_params)` plus a cross-column CHECK that parameterless variants have null params and the rest don't. The variant *is* the renderer's dispatch key — no more `model_override` indirection from coin identity to shape.
|
||||
- **Procedural outlines, no Blender.** `outlines.ts` defines `circleOutline`, `polygonOutline`, `notchedCircleOutline`, `sineRoundOutline`, `flowerOutline`. `extrude.ts` builds a `BufferGeometry` from an outer outline (plus optional concentric inner for holes), with cap UVs rotated 90° so picture-up lands on the cylinder's local −Z (matching `buildCoinGeometry`), bottom cap V-mirrored, side-wall UVs wrapping the edge strip. `shape-variants.ts` dispatches `(shape_variant, shape_params) → outline params` for the extruded path (everything except round/irregular, which run through the cylinder `buildCoinGeometry`).
|
||||
- **Polygon construction guarantees.** `polygonOutline` builds each side as a circular arc through the two adjacent vertices (sagitta from `edge_roundness`) and each corner as a fillet internally tangent to both adjacent edge arcs (radius from `corner_roundness`). Both arcs' centres sit on the polygon-interior side of the perimeter, so curvature direction is consistent everywhere and the outline is **strictly convex by construction** for any (corner, edge, phase) in [0, 1]³ — no inflection points, no kinks. Output is also dynamically rescaled so the silhouette's farthest point from origin lands exactly at the requested `radius` regardless of params (without rescaling, `corner_roundness > 0` would shrink the silhouette since the corner-arc apex sits inside the original vertex circle). `corner_roundness` is floored internally at `CORNER_ROUNDNESS_FLOOR = 0.01` because a mathematically sharp vertex round-shades on the rim (the extrude builder smooths side-wall normals across vertices); no real coin has a perfectly sharp corner anyway.
|
||||
- **Per-coin tunability.** Shape params are starting points, not hardcoded constants. `bun run ingest add <id>` derives defaults from Numista's free-text shape, then prompts the user to accept or override each one inline before the row is upserted. Different coins with the same variant can carry different params (e.g. two holed coins with different `hole_ratio`s) — the renderer just reads the row. Editing later still works the normal way: `sqlite3 data/catalogue.db "UPDATE coins SET shape_params='…' WHERE id=…"`, with `json_valid()` enforcing JSON correctness at write time.
|
||||
- **Pile colliders stay `CylinderCollider`** regardless of the visual geometry. Visually correct via the procedural extrude, physically approximate for non-round shapes. Acceptable for this scale; custom mesh colliders are a future refinement.
|
||||
2. **Banknotes.** ✅
|
||||
- **DB & ingest.** `category='banknote'` (vs `coin` / `exonumia`). New `rectangular` shape variant joins the enum; the cross-column CHECK forces NULL `shape_params` for it. Numista's `size` / `size2` map to `width` / `height` (mm) — banknotes use these instead of `diameter`. The interactive shape-resolver prompt is bypassed for banknotes (always rectangular, nothing to ask).
|
||||
- **Atlas layout.** `bakeBanknoteAtlases` writes a stacked obverse-on-reverse atlas (no edge strip) sized to the note's own aspect — Numista banknotes range from squarish to long-portrait, so there's no fixed atlas shape. The long mm-edge maps to `size` (256 / 1024 px) so per-face pixel density stays comparable across orientations.
|
||||
- **Geometry.** `buildBanknoteGeometry` produces a thin box as a 5×2 grid of face vertices with creases at 0.25 / 0.5 / 0.75 of the long axis, bent [+θ, −θ, −θ] so the silhouette reads as "slightly folded" rather than crumpled. Two material groups (faces / sides). Portrait notes (height > width) are rotated 90° around the face normal so the long axis is consistent. Local frame matches the coin convention (faces ⊥ Y) so the showcase `PieceStage` slots in unchanged.
|
||||
- **Materials.** Paper-ish PBR — `metalness=0`, `roughness=0.85`. Face texture tinted to ~0.5 because the rect-area-light rig is calibrated for shiny `metalness=1` coins; the diffuse-only banknote would otherwise blow out. Sides are flat dominant-colour fill.
|
||||
- **Showcase.** Info panel switches to `↔ width · ↕ height` (no weight, no thickness — Numista carries neither for banknotes; the renderer uses a fixed 0.12 mm dummy thickness, visible edge-on but invisible head-on). Banknotes scale to ¼ in showcase so a 154 mm note doesn't dwarf a 23 mm coin in the same frame.
|
||||
- **Pile.** Excluded explicitly — `PILE_EXCLUDED_CATEGORIES = ['banknote']`, mirrored to the URL so cross-page nav back to /showcase carries no surprise inclusions. The category checkbox is disabled on /pile.
|
||||
3. **Production deploy.** `Dockerfile` (Bun base image), `docker-compose.yaml` with the data volume, sample `Caddyfile` documenting the three handlers (`/api/*` → proxy, `/atlases/*` → file_server, default → SPA fallback to the right HTML shell per route).
|
||||
|
||||
### `/shape-test` — dev tool
|
||||
|
||||
A dev-only sandbox at `/shape-test?id=<coin-id>` for tuning a coin's `shape_variant` + `shape_params` interactively before writing them to the DB. Loads the coin's atlas (if id is supplied), exposes a variant selector plus per-variant sliders, and emits a live-updating `UPDATE coins SET shape_variant=…, shape_params='…' WHERE id=…;` you can copy-paste into `sqlite3` (no write API on the server — the LRU cache + auth-free Bun handler made an in-app write path risky for the home-server deploy story, copy-paste is fine for an n=1 user). State is one-shot init from the URL; sliders don't sync back to the URL or to subsequent piece data.
|
||||
|
||||
## Known oddities
|
||||
|
||||
- **Two `@dimforge/rapier3d-compat` versions installed.** 0.12.0 sits at top level (pulled in transitively by `@types/three`); 0.19.2 is nested under `@react-three/rapier`. The runtime correctly uses 0.19.2. Our explicit import of `RigidBodyType` resolves to 0.12.0's enum, but the enum values match across the versions we care about. Worth knowing if anything ever starts misbehaving.
|
||||
- **StrictMode + outer Suspense + anything suspending inside the Canvas** (rapier WASM init on `/pile`; `useTexture` on `/shape-test`) used to lose the WebGL context on first mount. The bubble-up reaches the app-level Suspense in `main.tsx`, which unmounts the entire Canvas; under StrictMode's double-mount that disposes the renderer between the two mounts. Fix is always the same: wrap the suspending subtree in a *local* `<Suspense fallback={null}>` *inside* the Canvas so the suspension is contained. Anything new that calls `use()` / `useTexture()` / `useGLTF()` inside a Canvas should follow this pattern.
|
||||
@@ -1,38 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
@@ -1,21 +0,0 @@
|
||||
import process from 'process';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export const asyncSpawn = (command: string, args: string[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.stdout?.on('data', process.stdout.write);
|
||||
child.stderr?.on('data', process.stderr.write);
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolve(code);
|
||||
} else {
|
||||
reject(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { getTypes } from '@/api/types/get-types';
|
||||
import { generateImage } from '@/core/utils/merge-textures';
|
||||
|
||||
const coins = (await getTypes({ category: 'coins' })).filter(coin => coin.shape !== 'Round');
|
||||
|
||||
for (const [coinIndex, coin] of coins.entries()) {
|
||||
console.log('coin', coinIndex, 'of', coins.length);
|
||||
await generateImage(coin.id, 'base');
|
||||
await generateImage(coin.id, 'bump');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
export const db = new Database('./prisma/main.db');
|
||||
@@ -1,85 +0,0 @@
|
||||
import path from 'path';
|
||||
import Vibrant from 'node-vibrant';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { asyncSpawn } from './async-spawn';
|
||||
import puppeteer, { Page } from 'puppeteer';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { getTextureUrl } from '@/core/utils/texture-urls';
|
||||
|
||||
const baseDir = './public';
|
||||
|
||||
export const downloadFile = async (page: Page, url: string): Promise<[string, string, string?]> => {
|
||||
const filepath = url.split('catalogue')[1];
|
||||
const folder = path.join(baseDir, filepath.split('/').slice(0, -1).join('/'));
|
||||
console.log(url);
|
||||
await asyncSpawn('mkdir', ['-p', folder]);
|
||||
const destination = path.join(baseDir, filepath);
|
||||
|
||||
const pageRes = await page.goto(url, { waitUntil: 'load' });
|
||||
|
||||
if (pageRes) {
|
||||
await writeFile(destination, await pageRes.buffer());
|
||||
}
|
||||
|
||||
const destinationWebp = destination.replace(/\.\w*$/g, '.webp')
|
||||
|
||||
await asyncSpawn('cwebp', [destination, '-o', destinationWebp])
|
||||
|
||||
let color: string | undefined;
|
||||
const palette = await Vibrant.from(destination).getPalette();
|
||||
|
||||
if (palette.Vibrant) {
|
||||
color = 'rgb(' + palette.Vibrant.rgb.join(',') + ')';
|
||||
}
|
||||
|
||||
return [destinationWebp, destination, color];
|
||||
};
|
||||
|
||||
export const fetchTextures = async (type: NumistaType) => {
|
||||
try {
|
||||
const browser = await puppeteer.launch({ headless: false });
|
||||
const page = await browser.newPage();
|
||||
const texturesToBump: string[] = [];
|
||||
page.setViewport({ width: 1280, height: 926 });
|
||||
|
||||
if (type?.obverse?.picture) {
|
||||
const [destinationWebp, destination, color] = await downloadFile(page, type.obverse.picture);
|
||||
type.obverse.picture = destinationWebp.split('public')[1];
|
||||
texturesToBump.push(`./public${destination.split('public')[1]}`);
|
||||
|
||||
if (color) {
|
||||
type.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
if (type?.reverse?.picture) {
|
||||
const [destinationWebp, destination] = await downloadFile(page, type.reverse.picture);
|
||||
type.reverse.picture = destinationWebp.split('public')[1];
|
||||
texturesToBump.push(`./public${destination.split('public')[1]}`);
|
||||
}
|
||||
|
||||
if (type?.edge?.picture) {
|
||||
const [destinationWebp, destination] = await downloadFile(page, type.edge.picture);
|
||||
type.edge.picture = destinationWebp.split('public')[1];
|
||||
await asyncSpawn('rm', [`./public${destination.split('public')[1]}`])
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const bumpPngs = []
|
||||
for (const texture of texturesToBump) {
|
||||
await asyncSpawn('./bin/generate-depth.sh', [texture]);
|
||||
const bumpTextureWebp = getTextureUrl(texture, 'bump')
|
||||
const bumpTexturePng = bumpTextureWebp.replace(/(\.[^/.]+)$/, '.png')
|
||||
bumpPngs.push(bumpTexturePng)
|
||||
await asyncSpawn('cwebp', [bumpTexturePng, '-o', bumpTextureWebp])
|
||||
}
|
||||
|
||||
await asyncSpawn('rm', [...texturesToBump,...bumpPngs])
|
||||
|
||||
return type;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ -z $1 ]]; then
|
||||
echo "Usage: $0 <image file>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$1" ]]; then
|
||||
echo "$1 is not a file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file=$(realpath "$1")
|
||||
mkdir -p ./tmp
|
||||
rm -fr ./tmp/*
|
||||
cp "$file" ./tmp
|
||||
|
||||
(
|
||||
cd ./bin
|
||||
|
||||
if [[ ! -d "python" ]]; then
|
||||
python -m venv python
|
||||
fi
|
||||
|
||||
source python/bin/activate
|
||||
|
||||
if [[ ! -d "marigold" ]]; then
|
||||
git clone https://github.com/prs-eth/Marigold.git marigold
|
||||
fi
|
||||
|
||||
cd marigold
|
||||
pip install -r requirements.txt
|
||||
|
||||
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True python run.py \
|
||||
--input_rgb ../../tmp \
|
||||
--output_dir ../../tmp \
|
||||
--checkpoint prs-eth/marigold-v1-0 \
|
||||
--denoise_steps 50 \
|
||||
--ensemble_size 10
|
||||
|
||||
cp ../../tmp/depth_bw/*_pred.png "$(dirname "$file")"
|
||||
|
||||
(
|
||||
cd "$(dirname "$file")"
|
||||
target=(*_pred.png)
|
||||
mv "$target" "${target/_pred.png/_bump.png}"
|
||||
)
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Usage
|
||||
# `./get-type.sh <coin|banknote> <country> <id> <count (optional)>`
|
||||
|
||||
type=$1
|
||||
dir="./public/$1s/$2"
|
||||
tmpdir="./public/new-$1s/$2"
|
||||
id=$3
|
||||
count=${4:-1}
|
||||
|
||||
if [ -f "$dir/$id.json" ]; then
|
||||
echo "Already exists in main dir"
|
||||
oldcount=$(cat $dir/$id.json | jq '.count')
|
||||
if [ $oldcount == null ]; then
|
||||
oldcount=1
|
||||
fi
|
||||
newcount=$count+$oldcount
|
||||
json=$(cat $dir/$id.json | jq '.count='$newcount)
|
||||
echo $json | jq > $dir/$id.json
|
||||
elif [ -f "$tmpdir/$id.json" ]; then
|
||||
echo "Already exists in tmp dir"
|
||||
oldcount=$(cat $tmpdir/$id.json | jq '.count')
|
||||
if [ $oldcount == null ]; then
|
||||
oldcount=1
|
||||
fi
|
||||
newcount=$count+$oldcount
|
||||
json=$(cat $tmpdir/$id.json | jq '.count='$newcount)
|
||||
echo $json | jq > $tmpdir/$id.json
|
||||
else
|
||||
mkdir -p $tmpdir
|
||||
curl -X GET "https://api.numista.com/api/v3/types/$id" -H "Numista-API-Key: egr10utzWmYztFrZhJSRGF7Tv64RA3b2S4xdz4di" | jq '.count='$count > $tmpdir/$id.json
|
||||
cat $tmpdir/$id.json | jq '.title'
|
||||
fi
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { asyncSpawn } from './async-spawn';
|
||||
import { fetchTextures } from './fetch-textures';
|
||||
import { db } from './db';
|
||||
import { handleInsertType, handleUpdateTypeCount } from './insert';
|
||||
import { readFile, rm } from 'fs/promises';
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [id, count] = process.argv.slice(2, 4).map(value => Number(value));
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Invalid id ' + id);
|
||||
}
|
||||
|
||||
if (!count) {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
const query = db.query('SELECT * FROM NumistaType WHERE id = ' + id + ';');
|
||||
let type = query.get() as NumistaType | null;
|
||||
|
||||
if (type) {
|
||||
console.log(type.title);
|
||||
type.count = (type.count ?? 1) + count;
|
||||
handleUpdateTypeCount(type);
|
||||
} else {
|
||||
await asyncSpawn('sh', [
|
||||
'-c',
|
||||
`curl https://api.numista.com/api/v3/types/${id} -H Numista-API-Key:egr10utzWmYztFrZhJSRGF7Tv64RA3b2S4xdz4di | jq '.count='${count} > tmp-${id}.json`
|
||||
]);
|
||||
|
||||
type = JSON.parse(await readFile(`tmp-${id}.json`, { encoding: 'utf-8' }));
|
||||
|
||||
if (!type) {
|
||||
throw new Error('Invalid response for id ' + id);
|
||||
}
|
||||
|
||||
console.log(type.title);
|
||||
|
||||
await rm(`tmp-${id}.json`);
|
||||
|
||||
type = await fetchTextures(type);
|
||||
|
||||
if (!type) {
|
||||
throw new Error('Error processing textures for id ' + id);
|
||||
}
|
||||
|
||||
handleInsertType(type);
|
||||
}
|
||||
|
||||
db.close();
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
CREATE TABLE Issuer (
|
||||
code TEXT NOT NULL COLLATE NOCASE PRIMARY KEY,
|
||||
name TEXT NOT NULL COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE TABLE Currency (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
full_name TEXT NOT NULL COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE TABLE Value (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
text TEXT COLLATE NOCASE,
|
||||
numeric_value REAL,
|
||||
numerator INTEGER,
|
||||
denominator INTEGER,
|
||||
currency_id INTEGER,
|
||||
FOREIGN KEY (currency_id) REFERENCES Currency(id)
|
||||
);
|
||||
|
||||
CREATE TABLE Demonetization (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
is_demonetized BOOLEAN NOT NULL,
|
||||
demonetization_date TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE Composition (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
text TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE Technique (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
text TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE Reverse (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
engravers TEXT,
|
||||
designers TEXT,
|
||||
description TEXT,
|
||||
lettering TEXT,
|
||||
lettering_scripts TEXT,
|
||||
unabridged_legend TEXT,
|
||||
lettering_translation TEXT,
|
||||
picture TEXT,
|
||||
thumbnail TEXT,
|
||||
picture_copyright TEXT,
|
||||
picture_copyright_url TEXT,
|
||||
picture_license_name TEXT,
|
||||
picture_license_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE Obverse (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
engravers TEXT,
|
||||
designers TEXT,
|
||||
description TEXT,
|
||||
lettering TEXT,
|
||||
lettering_scripts TEXT,
|
||||
unabridged_legend TEXT,
|
||||
lettering_translation TEXT,
|
||||
picture TEXT,
|
||||
thumbnail TEXT,
|
||||
picture_copyright TEXT,
|
||||
picture_copyright_url TEXT,
|
||||
picture_license_name TEXT,
|
||||
picture_license_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE Edge (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
engravers TEXT,
|
||||
designers TEXT,
|
||||
description TEXT,
|
||||
lettering TEXT,
|
||||
lettering_scripts TEXT,
|
||||
unabridged_legend TEXT,
|
||||
lettering_translation TEXT,
|
||||
picture TEXT,
|
||||
thumbnail TEXT,
|
||||
picture_copyright TEXT,
|
||||
picture_copyright_url TEXT,
|
||||
picture_license_name TEXT,
|
||||
picture_license_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE Watermark (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
engravers TEXT,
|
||||
designers TEXT,
|
||||
description TEXT,
|
||||
lettering TEXT,
|
||||
lettering_scripts TEXT,
|
||||
unabridged_legend TEXT,
|
||||
lettering_translation TEXT,
|
||||
picture TEXT,
|
||||
thumbnail TEXT,
|
||||
picture_copyright TEXT,
|
||||
picture_copyright_url TEXT,
|
||||
picture_license_name TEXT,
|
||||
picture_license_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE NumistaType (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
url TEXT,
|
||||
title TEXT NOT NULL COLLATE NOCASE,
|
||||
category TEXT NOT NULL CHECK (category IN ('coin', 'banknote', 'exonumia')),
|
||||
issuer_code TEXT,
|
||||
min_year INTEGER,
|
||||
max_year INTEGER,
|
||||
type TEXT COLLATE NOCASE,
|
||||
value_id INTEGER,
|
||||
demonetization_id INTEGER,
|
||||
shape TEXT,
|
||||
composition_id INTEGER,
|
||||
technique_id INTEGER,
|
||||
weight REAL,
|
||||
size REAL,
|
||||
size2 REAL,
|
||||
thickness REAL,
|
||||
orientation TEXT CHECK (orientation IN ('coin', 'medal', 'variable', 'three', 'nine')),
|
||||
obverse_id INTEGER,
|
||||
reverse_id INTEGER,
|
||||
edge_id INTEGER,
|
||||
watermark_id INTEGER,
|
||||
series TEXT,
|
||||
commemorated_topic TEXT,
|
||||
comments TEXT,
|
||||
count INTEGER NOT NULL,
|
||||
color TEXT,
|
||||
FOREIGN KEY (issuer_code) REFERENCES Issuer(code),
|
||||
FOREIGN KEY (value_id) REFERENCES Value(id),
|
||||
FOREIGN KEY (demonetization_id) REFERENCES Demonetization(id),
|
||||
FOREIGN KEY (composition_id) REFERENCES Composition(id),
|
||||
FOREIGN KEY (technique_id) REFERENCES Technique(id),
|
||||
FOREIGN KEY (obverse_id) REFERENCES Obverse(id),
|
||||
FOREIGN KEY (reverse_id) REFERENCES Reverse(id),
|
||||
FOREIGN KEY (edge_id) REFERENCES Edge(id),
|
||||
FOREIGN KEY (watermark_id) REFERENCES Watermark(id)
|
||||
);
|
||||
|
||||
CREATE TABLE RulerGroup (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE Ruler (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
wikidata_id TEXT,
|
||||
group_id INTEGER,
|
||||
FOREIGN KEY (group_id) REFERENCES RulerGroup(id)
|
||||
);
|
||||
|
||||
CREATE TABLE TypeRuler (
|
||||
type_id INTEGER NOT NULL,
|
||||
ruler_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (type_id) REFERENCES NumistaType(id),
|
||||
FOREIGN KEY (ruler_id) REFERENCES Ruler(id),
|
||||
UNIQUE(type_id, ruler_id)
|
||||
);
|
||||
|
||||
CREATE TABLE TypeRelated (
|
||||
type1_id INTEGER NOT NULL,
|
||||
type2_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (type1_id) REFERENCES NumistaType(id),
|
||||
FOREIGN KEY (type2_id) REFERENCES NumistaType(id),
|
||||
UNIQUE(type1_id, type2_id)
|
||||
);
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { db } from './db';
|
||||
|
||||
const bannedKeys = ['issuing_entity'];
|
||||
|
||||
const omit = (data: object, keys: string[] = []) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(data).filter(
|
||||
([key, value]) => !keys.includes(key) && (typeof value === 'number' || typeof value === 'boolean' || !!value)
|
||||
)
|
||||
);
|
||||
|
||||
const exists = (table: string, primaryKey: string, value: unknown) => {
|
||||
const stmt = `SELECT * FROM ${table} WHERE ${primaryKey}=${typeof value === 'string' ? `'${value}'` : value}`;
|
||||
|
||||
return !!db.prepare(stmt).get();
|
||||
};
|
||||
|
||||
const inserter = (table: string, data: Record<string, unknown>, primaryKey?: string) => {
|
||||
if (primaryKey && exists(table, primaryKey, data[primaryKey])) {
|
||||
console.error(`${table} ${data[primaryKey]} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.entries(data).filter(([key]) => !bannedKeys.includes(key));
|
||||
|
||||
const stmt = `INSERT INTO ${table} (${entries.map(([key]) => key).join(', ')}) VALUES (${entries
|
||||
.map(([, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return `'${value.join(', ').replaceAll("'", "''")}'`;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.join(', ')});`;
|
||||
|
||||
console.log(stmt);
|
||||
db.exec(stmt);
|
||||
};
|
||||
|
||||
export const handleInsertType = (type: NumistaType) => {
|
||||
if (type.issuer) {
|
||||
inserter('Issuer', omit(type.issuer), 'code');
|
||||
}
|
||||
if (type.value) {
|
||||
if (type.value.currency) {
|
||||
inserter('Currency', omit(type.value.currency), 'id');
|
||||
}
|
||||
|
||||
inserter(
|
||||
'Value',
|
||||
omit(
|
||||
{
|
||||
...type.value,
|
||||
currency_id: type.value.currency?.id,
|
||||
id: type.id
|
||||
},
|
||||
['currency']
|
||||
),
|
||||
'id'
|
||||
);
|
||||
}
|
||||
if (type.demonetization) {
|
||||
inserter('Demonetization', omit({ ...type.demonetization, id: type.id }), 'id');
|
||||
}
|
||||
if (type.composition) {
|
||||
inserter('Composition', omit({ ...type.composition, id: type.id }), 'id');
|
||||
}
|
||||
if (type.technique) {
|
||||
inserter('Technique', omit({ ...type.technique, id: type.id }), 'id');
|
||||
}
|
||||
if (type.obverse) {
|
||||
inserter('Obverse', omit({ ...type.obverse, id: type.id }), 'id');
|
||||
}
|
||||
if (type.reverse) {
|
||||
inserter('Reverse', omit({ ...type.reverse, id: type.id }), 'id');
|
||||
}
|
||||
if (type.edge) {
|
||||
inserter('Edge', omit({ ...type.edge, id: type.id }), 'id');
|
||||
}
|
||||
if (type.watermark) {
|
||||
inserter('Watermark', omit({ ...type.watermark, id: type.id }), 'id');
|
||||
}
|
||||
|
||||
inserter(
|
||||
'NumistaType',
|
||||
omit(
|
||||
{
|
||||
...type,
|
||||
issuer_code: type.issuer?.code,
|
||||
count: type.count || 1,
|
||||
...(type.value && { value_id: type.id }),
|
||||
...(type.demonetization && { demonetization_id: type.id }),
|
||||
...(type.composition && { composition_id: type.id }),
|
||||
...(type.technique && { technique_id: type.id }),
|
||||
...(type.obverse && { obverse_id: type.id }),
|
||||
...(type.reverse && { reverse_id: type.id }),
|
||||
...(type.edge && { edge_id: type.id }),
|
||||
...(type.watermark && { watermark_id: type.id })
|
||||
},
|
||||
[
|
||||
'issuer',
|
||||
'value',
|
||||
'ruler',
|
||||
'obverse',
|
||||
'reverse',
|
||||
'edge',
|
||||
'watermark',
|
||||
'tags',
|
||||
'references',
|
||||
'mints',
|
||||
'printers',
|
||||
'demonetization',
|
||||
'composition',
|
||||
'technique',
|
||||
'related_types'
|
||||
]
|
||||
),
|
||||
'id'
|
||||
);
|
||||
|
||||
if (type.ruler?.length) {
|
||||
for (const ruler of type.ruler) {
|
||||
if (ruler.group) {
|
||||
inserter('RulerGroup', omit(ruler.group), 'id');
|
||||
}
|
||||
|
||||
inserter(
|
||||
'Ruler',
|
||||
omit(
|
||||
{
|
||||
...ruler,
|
||||
group_id: ruler.group?.id
|
||||
},
|
||||
['group']
|
||||
),
|
||||
'id'
|
||||
);
|
||||
|
||||
try {
|
||||
inserter('TypeRuler', {
|
||||
type_id: type.id,
|
||||
ruler_id: ruler.id
|
||||
});
|
||||
} catch {
|
||||
//ok
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdateTypeCount = (type: NumistaType) => {
|
||||
if (!exists('NumistaType', 'id', type.id)) {
|
||||
console.error(`id ${type.id} does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt = `UPDATE NumistaType SET count = ${type.count} WHERE id = ${type.id}`;
|
||||
|
||||
console.log(stmt);
|
||||
db.exec(stmt);
|
||||
};
|
||||
|
||||
export const handleRelated = (type: NumistaType) => {
|
||||
if (type.related_types?.length) {
|
||||
for (const related_type of type.related_types) {
|
||||
if (exists('NumistaType', 'id', related_type.id)) {
|
||||
inserter(
|
||||
'TypeRelated',
|
||||
omit({
|
||||
type1_id: type.id,
|
||||
type2_id: related_type.id
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
/// <reference lib="dom.iterable" />
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference types="bun-types" />
|
||||
+10
-9
@@ -1,13 +1,14 @@
|
||||
name: coins
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
additional_contexts:
|
||||
- photos=/deploy/photos
|
||||
ports:
|
||||
- '${HOST_PORT:-3000}:3000'
|
||||
volumes:
|
||||
- ${PHOTOS_DIR:-./public/photos}:/app/public/photos
|
||||
server:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- "${DATA_DIR:-./data}:/app/data"
|
||||
environment:
|
||||
COINS_DATA_DIR: /app/data
|
||||
PORT: "3000"
|
||||
NUMISTA_API_KEY: ${NUMISTA_API_KEY:-}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Coins</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400..800;1,400..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
redirects: () => [
|
||||
{
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
source: '/showcase-banknotes'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+33
-43
@@ -2,52 +2,42 @@
|
||||
"name": "coins",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "bun build-textures",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"get-type": "bun ./bin/get-type.ts",
|
||||
"build-textures": "bun ./bin/build-textures.ts",
|
||||
"generate-depth": "./bin/generate-depth.sh"
|
||||
"dev": "trap 'kill 0' EXIT; bun run dev:server & bun run dev:web",
|
||||
"dev:web": "vite --host",
|
||||
"dev:server": "bun --watch src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"start": "bun src/server/index.ts",
|
||||
"ingest": "bun src/ingest/cli.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.1",
|
||||
"@react-three/cannon": "^6.6.0",
|
||||
"@react-three/drei": "^9.83.9",
|
||||
"@react-three/fiber": "^8.13.0",
|
||||
"@tanstack/react-query": "^4.35.3",
|
||||
"@tanstack/react-query-devtools": "^4.35.3",
|
||||
"next": "^13.5.1",
|
||||
"prettier": "^3.0.3",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.6.2",
|
||||
"prisma": "^5.4.1",
|
||||
"puppeteer": "^23.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-div-100vh": "^0.7.0",
|
||||
"react-dom": "18.2.0",
|
||||
"three": "0.152.2",
|
||||
"zustand": "^4.4.1"
|
||||
"@clack/prompts": "^1.5.1",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@react-three/rapier": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"lru-cache": "^11.5.1",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"nuqs": "^2.8.9",
|
||||
"react": "^19.2.7",
|
||||
"react-aria-components": "^1.18.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^8.0.16",
|
||||
"wouter": "^3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^13.5.4",
|
||||
"@tanstack/eslint-plugin-query": "^4.34.1",
|
||||
"@types/node": "20.6.0",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@types/three": "0.152.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@untile/eslint-config-typescript-react": "^2.0.1",
|
||||
"@untile/prettier-config": "^2.0.1",
|
||||
"bun-types": "^1.0.3",
|
||||
"eslint": "8.49.0",
|
||||
"eslint-config-next": "13.4.19",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"node-vibrant": "^3.2.1-alpha.1",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"prettier": "@untile/prettier-config"
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^6.0.3",
|
||||
"vite-plugin-svgr": "^5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,199 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./main.db"
|
||||
}
|
||||
|
||||
model Composition {
|
||||
id Int @id @default(autoincrement())
|
||||
text String?
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model Currency {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
full_name String
|
||||
value Value[]
|
||||
}
|
||||
|
||||
model Demonetization {
|
||||
id Int @id @default(autoincrement())
|
||||
is_demonetized Boolean
|
||||
demonetization_date String?
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model Edge {
|
||||
id Int @id @default(autoincrement())
|
||||
engravers String?
|
||||
designers String?
|
||||
description String?
|
||||
lettering String?
|
||||
lettering_scripts String?
|
||||
unabridged_legend String?
|
||||
lettering_translation String?
|
||||
picture String?
|
||||
thumbnail String?
|
||||
picture_copyright String?
|
||||
picture_copyright_url String?
|
||||
picture_license_name String?
|
||||
picture_license_url String?
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model Issuer {
|
||||
iso String?
|
||||
code String @id
|
||||
name String
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model NumistaType {
|
||||
id Int @id @default(autoincrement())
|
||||
url String?
|
||||
title String
|
||||
category String
|
||||
issuer_code String?
|
||||
min_year Int?
|
||||
max_year Int?
|
||||
type String?
|
||||
value_id Int?
|
||||
demonetization_id Int?
|
||||
shape String?
|
||||
composition_id Int?
|
||||
technique_id Int?
|
||||
weight Float?
|
||||
size Float?
|
||||
thickness Float?
|
||||
orientation String?
|
||||
obverse_id Int?
|
||||
reverse_id Int?
|
||||
edge_id Int?
|
||||
watermark_id Int?
|
||||
series String?
|
||||
commemorated_topic String?
|
||||
comments String?
|
||||
count Int @default(1)
|
||||
color String?
|
||||
watermark Watermark? @relation(fields: [watermark_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
edge Edge? @relation(fields: [edge_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
reverse Reverse? @relation(fields: [reverse_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
obverse Obverse? @relation(fields: [obverse_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
technique Technique? @relation(fields: [technique_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
composition Composition? @relation(fields: [composition_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
demonetization Demonetization? @relation(fields: [demonetization_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
value Value? @relation(fields: [value_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
issuer Issuer? @relation(fields: [issuer_code], references: [code], onDelete: NoAction, onUpdate: NoAction)
|
||||
relatedB TypeRelated[] @relation("TypeRelated_type2_idToType")
|
||||
relatedA TypeRelated[] @relation("TypeRelated_type1_idToType")
|
||||
rulers TypeRuler[]
|
||||
}
|
||||
|
||||
model Obverse {
|
||||
id Int @id @default(autoincrement())
|
||||
engravers String?
|
||||
designers String?
|
||||
description String?
|
||||
lettering String?
|
||||
lettering_scripts String?
|
||||
unabridged_legend String?
|
||||
lettering_translation String?
|
||||
picture String?
|
||||
thumbnail String?
|
||||
picture_copyright String?
|
||||
picture_copyright_url String?
|
||||
picture_license_name String?
|
||||
picture_license_url String?
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model Reverse {
|
||||
id Int @id @default(autoincrement())
|
||||
engravers String?
|
||||
designers String?
|
||||
description String?
|
||||
lettering String?
|
||||
lettering_scripts String?
|
||||
unabridged_legend String?
|
||||
lettering_translation String?
|
||||
picture String?
|
||||
thumbnail String?
|
||||
picture_copyright String?
|
||||
picture_copyright_url String?
|
||||
picture_license_name String?
|
||||
picture_license_url String?
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model Ruler {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
wikidata_id String?
|
||||
group_id Int?
|
||||
group RulerGroup? @relation(fields: [group_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
TypeRuler TypeRuler[]
|
||||
}
|
||||
|
||||
model RulerGroup {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
rulers Ruler[]
|
||||
}
|
||||
|
||||
model Technique {
|
||||
id Int @id @default(autoincrement())
|
||||
text String?
|
||||
type NumistaType[]
|
||||
}
|
||||
|
||||
model TypeRelated {
|
||||
type1_id Int
|
||||
type2_id Int
|
||||
type1 NumistaType @relation("TypeRelated_type2_idToType", fields: [type2_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
type2 NumistaType @relation("TypeRelated_type1_idToType", fields: [type1_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@unique([type1_id, type2_id], map: "sqlite_autoindex_TypeRelated_1")
|
||||
}
|
||||
|
||||
model TypeRuler {
|
||||
type_id Int
|
||||
ruler_id Int
|
||||
ruler Ruler @relation(fields: [ruler_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
type NumistaType @relation(fields: [type_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@unique([type_id, ruler_id], map: "sqlite_autoindex_TypeRuler_1")
|
||||
}
|
||||
|
||||
model Value {
|
||||
id Int @id @default(autoincrement())
|
||||
text String?
|
||||
numeric_value Float?
|
||||
numerator Int?
|
||||
denominator Int?
|
||||
currency_id Int?
|
||||
type NumistaType[]
|
||||
currency Currency? @relation(fields: [currency_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model Watermark {
|
||||
id Int @id @default(autoincrement())
|
||||
engravers String?
|
||||
designers String?
|
||||
description String?
|
||||
lettering String?
|
||||
lettering_scripts String?
|
||||
unabridged_legend String?
|
||||
lettering_translation String?
|
||||
picture String?
|
||||
thumbnail String?
|
||||
picture_copyright String?
|
||||
picture_copyright_url String?
|
||||
picture_license_name String?
|
||||
picture_license_url String?
|
||||
type NumistaType[]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+33
@@ -0,0 +1,33 @@
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import type { IssuerCount } from '@/db/types';
|
||||
import type { PieceFilters, PiecesResponse } from '@/filter-types';
|
||||
|
||||
const cache = new LRUCache<string, Promise<unknown>>({ max: 32 });
|
||||
|
||||
function fetchOnce<T>(url: string): Promise<T> {
|
||||
const hit = cache.get(url);
|
||||
if (hit) return hit as Promise<T>;
|
||||
const promise = fetch(url).then((r) => r.json() as Promise<T>);
|
||||
cache.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function serializeFilters(filters?: PieceFilters): string {
|
||||
if (!filters) return '';
|
||||
const params = new URLSearchParams();
|
||||
if (filters.countries?.length) params.set('countries', filters.countries.join(','));
|
||||
if (filters.currencies?.length) params.set('currencies', filters.currencies.join(','));
|
||||
if (filters.faceValue !== undefined) params.set('face_value', String(filters.faceValue));
|
||||
if (filters.categories?.length) params.set('categories', filters.categories.join(','));
|
||||
if (filters.editions?.length) params.set('editions', filters.editions.join(','));
|
||||
if (filters.shapes?.length) params.set('shapes', filters.shapes.join(','));
|
||||
if (filters.yearMin !== undefined) params.set('year_min', String(filters.yearMin));
|
||||
if (filters.yearMax !== undefined) params.set('year_max', String(filters.yearMax));
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export const fetchPieces = (filters?: PieceFilters) =>
|
||||
fetchOnce<PiecesResponse>(`/api/pieces${serializeFilters(filters)}`);
|
||||
|
||||
export const fetchCountryCounts = () => fetchOnce<IssuerCount[]>('/api/country-counts');
|
||||
@@ -1,12 +0,0 @@
|
||||
import { prisma } from '@/api';
|
||||
|
||||
export const getCountries = async (filter?: string) => {
|
||||
return await prisma.issuer.findMany({
|
||||
...(filter && {
|
||||
orderBy: { name: 'asc' },
|
||||
where: {
|
||||
name: { contains: filter }
|
||||
}
|
||||
})
|
||||
});
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { prisma } from '@/api';
|
||||
|
||||
export const getCountryCounts = async (category?: 'coins' | 'banknotes') => {
|
||||
const countryBanknoteCounts =
|
||||
category !== 'coins'
|
||||
? await prisma.numistaType.groupBy({
|
||||
_count: true,
|
||||
_sum: {
|
||||
count: true
|
||||
},
|
||||
by: ['issuer_code'],
|
||||
where: {
|
||||
category: {
|
||||
equals: 'banknote'
|
||||
}
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
const countryCoinCounts =
|
||||
category !== 'banknotes'
|
||||
? await prisma.numistaType.groupBy({
|
||||
_count: true,
|
||||
_sum: {
|
||||
count: true
|
||||
},
|
||||
by: ['issuer_code'],
|
||||
where: {
|
||||
category: {
|
||||
not: 'banknote'
|
||||
}
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
const countries = await prisma.issuer.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
const map = countries.map(country => {
|
||||
const banknoteCounts = countryBanknoteCounts.find(({ issuer_code }) => issuer_code === country.code);
|
||||
const coinCounts = countryCoinCounts.find(({ issuer_code }) => issuer_code === country.code);
|
||||
|
||||
return {
|
||||
...country,
|
||||
...(banknoteCounts && {
|
||||
banknotes: {
|
||||
count: banknoteCounts?._count,
|
||||
sum: banknoteCounts?._sum?.count
|
||||
}
|
||||
}),
|
||||
...(coinCounts && {
|
||||
coins: {
|
||||
count: coinCounts?._count,
|
||||
sum: coinCounts?._sum?.count
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return map;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { prisma } from '@/api';
|
||||
|
||||
export const getCurrencies = async (filter?: string) => {
|
||||
return (
|
||||
await prisma.currency.groupBy({
|
||||
by: 'name',
|
||||
...(filter && {
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
full_name: { contains: filter },
|
||||
name: { contains: filter }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
).map(({ name }) => name);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -1,43 +0,0 @@
|
||||
import { include } from './relations';
|
||||
import { prisma } from '@/api';
|
||||
|
||||
const baseTypes = await prisma.numistaType.findMany({
|
||||
select: {
|
||||
count: true,
|
||||
id: true
|
||||
},
|
||||
where: {
|
||||
category: {
|
||||
not: 'banknote'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const total = baseTypes.reduce((total, type) => total + (type.count ?? 1), 0);
|
||||
|
||||
export const getRandomType = async () => {
|
||||
const target = Math.random() * total;
|
||||
let iter = 0;
|
||||
let typeId = 0;
|
||||
|
||||
baseTypes.some((type, index) => {
|
||||
iter += baseTypes[index].count ?? 1;
|
||||
|
||||
if (iter >= target) {
|
||||
typeId = type.id;
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!typeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await prisma.numistaType.findFirst({
|
||||
include,
|
||||
where: {
|
||||
id: typeId
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { include } from './relations';
|
||||
import { prisma } from '@/api';
|
||||
|
||||
export const getType = async (id: number) => {
|
||||
const type = await prisma.numistaType.findUnique({
|
||||
include,
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
return type as unknown as NumistaType | null;
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { include } from './relations';
|
||||
import { prisma } from '@/api';
|
||||
|
||||
type Props = {
|
||||
category?: 'coins' | 'banknotes';
|
||||
countries?: string[];
|
||||
currencies?: string[];
|
||||
faceValue?: number;
|
||||
search?: string;
|
||||
special?: 'yes' | 'no' | 'both';
|
||||
yearRange?: [number, number];
|
||||
};
|
||||
|
||||
export const getTypes = async ({ category, countries, currencies, faceValue, search, special, yearRange }: Props) => {
|
||||
const AND: Prisma.NumistaTypeWhereInput[] = [];
|
||||
const OR: Prisma.NumistaTypeWhereInput[] = [];
|
||||
|
||||
if (category === 'banknotes') {
|
||||
AND.push({
|
||||
category: 'banknote'
|
||||
});
|
||||
}
|
||||
|
||||
if (category === 'coins') {
|
||||
AND.push({
|
||||
category: {
|
||||
not: 'banknote'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (currencies?.length) {
|
||||
AND.push({
|
||||
value: {
|
||||
OR: currencies.map(currency => ({
|
||||
currency: {
|
||||
OR: [{ name: currency }, { full_name: currency }]
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (countries?.length) {
|
||||
AND.push({
|
||||
issuer: {
|
||||
OR: countries.map(country => ({
|
||||
OR: [
|
||||
{
|
||||
code: country
|
||||
},
|
||||
{
|
||||
name: country
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (faceValue) {
|
||||
AND.push({
|
||||
value: {
|
||||
numeric_value: {
|
||||
equals: faceValue
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (yearRange?.length === 2) {
|
||||
AND.push({
|
||||
AND: {
|
||||
max_year: {
|
||||
lte: yearRange[1]
|
||||
},
|
||||
min_year: {
|
||||
gte: yearRange[0]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (special === 'yes') {
|
||||
AND.push({
|
||||
NOT: {
|
||||
type: {
|
||||
contains: 'Standard'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (special === 'no') {
|
||||
AND.push({
|
||||
type: {
|
||||
contains: 'Standard'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (search) {
|
||||
OR.push({ title: { contains: search } });
|
||||
OR.push({
|
||||
issuer: {
|
||||
OR: [
|
||||
{
|
||||
code: { contains: search }
|
||||
},
|
||||
{
|
||||
name: { contains: search }
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
OR.push({
|
||||
value: {
|
||||
currency: {
|
||||
OR: [{ name: { contains: search } }, { full_name: { contains: search } }]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (AND.length) {
|
||||
OR.push({ AND });
|
||||
}
|
||||
|
||||
const types = await prisma.numistaType.findMany({
|
||||
include,
|
||||
...(OR.length > 0
|
||||
? {
|
||||
where: {
|
||||
OR
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
orderBy: [
|
||||
{
|
||||
min_year: 'asc'
|
||||
},
|
||||
{
|
||||
max_year: 'asc'
|
||||
},
|
||||
{
|
||||
value: {
|
||||
numeric_value: 'asc'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return types as unknown as NumistaType[];
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export const include: Prisma.NumistaTypeInclude = {
|
||||
edge: true,
|
||||
issuer: true,
|
||||
obverse: true,
|
||||
reverse: true,
|
||||
value: {
|
||||
include: {
|
||||
currency: true
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const ATLAS_LAYOUT = {
|
||||
256: { width: 256, height: 144, tile: 128, stripH: 16 },
|
||||
1024: { width: 1024, height: 576, tile: 512, stripH: 64 },
|
||||
} as const;
|
||||
|
||||
export type AtlasSize = keyof typeof ATLAS_LAYOUT;
|
||||
@@ -1,102 +0,0 @@
|
||||
import * as THREE from 'three';
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { useLoader } from '@react-three/fiber';
|
||||
|
||||
const textureProps = {
|
||||
bumpScale: 1 / 16,
|
||||
metalness: 0,
|
||||
roughness: 0.75
|
||||
};
|
||||
|
||||
const SafeMaterial = ({
|
||||
bumpMap,
|
||||
setWidth,
|
||||
side,
|
||||
url
|
||||
}: {
|
||||
bumpMap: THREE.Texture;
|
||||
setWidth?: Dispatch<SetStateAction<number>>;
|
||||
side: 'obverse' | 'reverse';
|
||||
url: string;
|
||||
}) => {
|
||||
const texture: THREE.Texture = useLoader(THREE.TextureLoader, url);
|
||||
|
||||
const index = useMemo(() => {
|
||||
switch (side) {
|
||||
case 'obverse':
|
||||
return 4;
|
||||
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
}, [side]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidth(() => {
|
||||
try {
|
||||
return (2 * texture.source.data.width) / texture.source.data.height;
|
||||
} catch {
|
||||
return 4;
|
||||
}
|
||||
});
|
||||
}, [setWidth, texture]);
|
||||
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
{...textureProps}
|
||||
attach={`material-${index}`}
|
||||
bumpMap={bumpMap}
|
||||
color={'#888'}
|
||||
map={texture}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BanknoteInstance = ({ obverse, reverse }: NumistaType) => {
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
const obverseNormalTexture = useMemo(() => {
|
||||
const texture = new THREE.TextureLoader().load('/photos/seamless-paper-normal-map.webp');
|
||||
|
||||
texture.repeat = new THREE.Vector2(2, 0.25);
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.offset = new THREE.Vector2(Math.random(), Math.random());
|
||||
|
||||
return texture;
|
||||
}, []);
|
||||
|
||||
const reverseNormalTexture = useMemo(() => {
|
||||
const texture = obverseNormalTexture.clone();
|
||||
texture.repeat = new THREE.Vector2(-2, 0.25);
|
||||
|
||||
return texture;
|
||||
}, [obverseNormalTexture]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<boxGeometry args={[width, 2, 0.005]} attach={'geometry'} />
|
||||
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<meshStandardMaterial {...textureProps} attach={`material-${index}`} color={'#888'} key={index} />
|
||||
))}
|
||||
|
||||
{obverse?.picture ? (
|
||||
<SafeMaterial bumpMap={obverseNormalTexture} setWidth={setWidth} side={'obverse'} url={obverse.picture} />
|
||||
) : (
|
||||
<meshStandardMaterial {...textureProps} attach={'material-4'} bumpMap={obverseNormalTexture} color={'#888'} />
|
||||
)}
|
||||
|
||||
{reverse?.picture ? (
|
||||
<SafeMaterial bumpMap={reverseNormalTexture} side={'reverse'} url={reverse.picture} />
|
||||
) : (
|
||||
<meshStandardMaterial {...textureProps} attach={'material-5'} bumpMap={reverseNormalTexture} color={'#888'} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import * as THREE from 'three';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { coinMaterialProps } from './index';
|
||||
import { getTextureUrl } from '@/core/utils/texture-urls';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useShowcaseStore } from '../showcase/store';
|
||||
|
||||
const SafeCoinMaterial = ({
|
||||
color,
|
||||
orientation,
|
||||
side,
|
||||
url
|
||||
}: Pick<NumistaType, 'color' | 'orientation'> & {
|
||||
side: 'edge' | 'obverse' | 'reverse';
|
||||
url?: string;
|
||||
}) => {
|
||||
const hasTextureBase = useShowcaseStore(state => state.textureBase);
|
||||
const hasTextureBump = useShowcaseStore(state => state.textureBump);
|
||||
const texture = useMemo(() => {
|
||||
try {
|
||||
if (hasTextureBase) {
|
||||
return new THREE.TextureLoader().load(url as string);
|
||||
}
|
||||
} catch {
|
||||
// Safe loading
|
||||
}
|
||||
}, [url, hasTextureBase]);
|
||||
|
||||
const textureBump = useMemo(() => {
|
||||
try {
|
||||
if (hasTextureBump) {
|
||||
return new THREE.TextureLoader().load(getTextureUrl(url, 'bump'));
|
||||
}
|
||||
} catch {
|
||||
// Safe loading
|
||||
}
|
||||
}, [url, hasTextureBump]);
|
||||
|
||||
const index = useMemo(() => {
|
||||
switch (side) {
|
||||
case 'edge':
|
||||
return 0;
|
||||
|
||||
case 'obverse':
|
||||
return 1;
|
||||
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}, [side]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!texture) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (side) {
|
||||
case 'edge':
|
||||
texture.repeat = new THREE.Vector2(4, 1);
|
||||
texture.offset = new THREE.Vector2(0.5, 0);
|
||||
texture.wrapS = THREE.MirroredRepeatWrapping;
|
||||
texture.wrapT = THREE.MirroredRepeatWrapping;
|
||||
break;
|
||||
|
||||
case 'reverse':
|
||||
if (orientation === 'medal') {
|
||||
texture.center = new THREE.Vector2(0.5, 0.5);
|
||||
texture.rotation = Math.PI;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [orientation, texture, side]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textureBump) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (side) {
|
||||
case 'reverse':
|
||||
if (orientation === 'medal') {
|
||||
textureBump.center = new THREE.Vector2(0.5, 0.5);
|
||||
textureBump.rotation = Math.PI;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [orientation, textureBump, side]);
|
||||
|
||||
if (!texture && !textureBump) {
|
||||
return <meshStandardMaterial {...coinMaterialProps} attach={`material-${index}`} color={color} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<meshStandardMaterial {...coinMaterialProps} attach={`material-${index}`} bumpMap={textureBump} map={texture} />
|
||||
);
|
||||
};
|
||||
|
||||
export const BaseCoin = ({ color, edge, obverse, orientation, reverse, shape }: NumistaType) => {
|
||||
const sides = useMemo(() => {
|
||||
const parsed = shape && Number(/(\d+)/.exec(shape)?.[0]);
|
||||
|
||||
if(parsed && parsed <= 10) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return 32;
|
||||
}, [shape]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<cylinderGeometry args={[1, 1, 1, sides, undefined, undefined, sides%2===0?0:Math.PI/2]} attach={'geometry'} />
|
||||
|
||||
<SafeCoinMaterial color={color} side={'edge'} url={edge?.picture} />
|
||||
|
||||
<SafeCoinMaterial color={color} side={'obverse'} url={obverse?.picture} />
|
||||
|
||||
<SafeCoinMaterial color={color} orientation={orientation} side={'reverse'} url={reverse?.picture} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import * as THREE from 'three';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { coinMaterialProps } from './index';
|
||||
import { useGLTF } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import { useShowcaseStore } from '../showcase/store';
|
||||
|
||||
const models = {
|
||||
BahamasCents10: '/models/bahamas-cents-10.gltf',
|
||||
BahamasCents15: '/models/bahamas-cents-15.gltf',
|
||||
EuroCents20: '/models/euro-cents-20.gltf',
|
||||
TostaoFurado: '/models/tostao-furado.gltf'
|
||||
} as const;
|
||||
|
||||
type Props = {
|
||||
coin: NumistaType;
|
||||
model: keyof typeof models;
|
||||
};
|
||||
|
||||
export function SpecialCoin({ coin, model }: Props) {
|
||||
// @ts-expect-error property `nodes` actually exists
|
||||
const { nodes } = useGLTF(models[model]);
|
||||
const showTextureBase = useShowcaseStore(state => state.textureBase);
|
||||
const showTextureBump = useShowcaseStore(state => state.textureBump);
|
||||
const texture = useMemo(() => {
|
||||
try {
|
||||
if (showTextureBase) {
|
||||
return new THREE.TextureLoader().load(`/api/merge-textures/${coin.id}`);
|
||||
}
|
||||
} catch {
|
||||
// Safe loading
|
||||
}
|
||||
}, [coin.id, showTextureBase]);
|
||||
|
||||
const textureBump = useMemo(() => {
|
||||
try {
|
||||
if (showTextureBump) {
|
||||
return new THREE.TextureLoader().load(`api/merge-textures/${coin.id}?type=bump`);
|
||||
}
|
||||
} catch {
|
||||
// Safe loading
|
||||
}
|
||||
}, [coin.id, showTextureBump]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<primitive object={nodes[model].geometry} />
|
||||
|
||||
<meshStandardMaterial {...coinMaterialProps} bumpMap={textureBump ?? null} map={texture ?? null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { BaseCoin } from './base';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { SpecialCoin } from './custom';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const coinMaterialProps = {
|
||||
bumpScale: -1 / 16,
|
||||
metalness: 1,
|
||||
roughness: 0.5
|
||||
};
|
||||
|
||||
export const CoinInstance = (coin: NumistaType) => {
|
||||
const model = useMemo(() => {
|
||||
if (
|
||||
(coin.value?.numeric_value === 0.2 && coin.value?.currency?.name === 'Euro') ||
|
||||
(coin.value?.numeric_value === 50 &&
|
||||
coin.value?.currency?.name === 'Peseta' &&
|
||||
coin.min_year &&
|
||||
coin.min_year >= 1990)
|
||||
) {
|
||||
return 'EuroCents20';
|
||||
}
|
||||
|
||||
if (
|
||||
coin.value?.numeric_value === 25 &&
|
||||
coin.value?.currency?.name === 'Peseta' &&
|
||||
coin.min_year &&
|
||||
coin.min_year >= 1990
|
||||
) {
|
||||
return 'TostaoFurado';
|
||||
}
|
||||
|
||||
if (coin.value?.currency?.name === 'Dollar' && coin.issuer?.code === 'bahamas') {
|
||||
switch (coin.value?.numeric_value) {
|
||||
case 0.15:
|
||||
return 'BahamasCents15';
|
||||
|
||||
case 0.1:
|
||||
return 'BahamasCents10';
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [coin]);
|
||||
|
||||
if (model) {
|
||||
return <SpecialCoin coin={coin} model={model} />;
|
||||
}
|
||||
|
||||
return <BaseCoin {...coin} />;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Checkbox } from 'react-aria-components';
|
||||
import CheckIcon from './icons/check.svg?react';
|
||||
|
||||
export function CheckRow({
|
||||
value,
|
||||
label,
|
||||
count,
|
||||
isDisabled,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Checkbox
|
||||
value={value}
|
||||
isDisabled={isDisabled}
|
||||
className="group flex items-center gap-2.5 py-1 cursor-pointer outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40"
|
||||
>
|
||||
<div className="w-3.5 h-3.5 border border-rule-strong flex items-center justify-center group-data-[selected]:bg-brass group-data-[selected]:border-brass group-data-[hovered]:border-brass group-data-[focus-visible]:border-brass transition-colors">
|
||||
<CheckIcon className="w-2.5 h-2.5 text-page opacity-0 group-data-[selected]:opacity-100" />
|
||||
</div>
|
||||
<span className="text-data text-ink flex-1">{label}</span>
|
||||
<span className="text-mini text-ink-faint tnum">{count}</span>
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Label, Slider, SliderOutput, SliderThumb, SliderTrack } from 'react-aria-components';
|
||||
|
||||
// Face value slider with "Any" as the leftmost stop. Position 0 == any (null), position k+1 ==
|
||||
// steps[k].
|
||||
export function FaceValueSlider({
|
||||
steps,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
steps: number[];
|
||||
value: number | null;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
const sliderIndex = value === null ? 0 : Math.max(0, steps.indexOf(value)) + 1;
|
||||
const label = sliderIndex === 0 ? 'Any' : String(steps[sliderIndex - 1]);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
value={sliderIndex}
|
||||
onChange={(v) => {
|
||||
const i = v as number;
|
||||
onChange(i === 0 ? null : (steps[i - 1] ?? null));
|
||||
}}
|
||||
minValue={0}
|
||||
maxValue={steps.length}
|
||||
step={1}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label className={`text-data tnum ${sliderIndex === 0 ? 'italic text-ink-dim' : 'text-ink'}`}>
|
||||
{label}
|
||||
</Label>
|
||||
<SliderTrack className="relative h-5 flex items-center cursor-pointer">
|
||||
{({ state }) => (
|
||||
<>
|
||||
<div className="absolute left-0 right-0 h-px bg-rule-strong" />
|
||||
{sliderIndex > 0 && (
|
||||
<div
|
||||
className="absolute left-0 h-px bg-brass"
|
||||
style={{ width: `${state.getThumbPercent(0) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
<SliderThumb className="block w-3 h-3 bg-brass data-[focus-visible]:bg-ink outline-none transition-colors" />
|
||||
</>
|
||||
)}
|
||||
</SliderTrack>
|
||||
<SliderOutput className="sr-only" />
|
||||
<div className="flex justify-between text-mini text-ink-faint tnum">
|
||||
<span>Any</span>
|
||||
{steps.map((s) => (
|
||||
<span key={s}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</Slider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 196 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M6 12h12M10 18h4" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 204 B |
@@ -1,359 +0,0 @@
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { MultiSelectCountriesFilter } from './multi-select-countries';
|
||||
import { MultiSelectCurrenciesFilter } from '@/components/filters/multi-select-currencies';
|
||||
import { MultiToggleFilter } from '@/components/filters/multi-toggle';
|
||||
import { ParsedUrlQuery } from 'querystring';
|
||||
import { Sidebar, SidebarProps } from 'primereact/sidebar';
|
||||
import { Slider } from 'primereact/slider';
|
||||
import { useEffect, useMemo, useReducer } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useShowcaseStore } from '@/components/showcase/store';
|
||||
|
||||
type Props = SidebarProps & {
|
||||
filterBanknotes?: boolean;
|
||||
filterTextures?: boolean;
|
||||
query: ParsedUrlQuery;
|
||||
};
|
||||
|
||||
type State = {
|
||||
banknotes: boolean;
|
||||
coins: boolean;
|
||||
countries: string[];
|
||||
currencies: string[];
|
||||
faceValue?: number;
|
||||
search: string;
|
||||
special: boolean;
|
||||
standard: boolean;
|
||||
yearRange: [number, number];
|
||||
};
|
||||
|
||||
type Action =
|
||||
| {
|
||||
key: 'currency';
|
||||
payload: string;
|
||||
}
|
||||
| {
|
||||
key: 'country';
|
||||
payload: string;
|
||||
}
|
||||
| {
|
||||
key: 'special' | 'standard' | 'coins' | 'banknotes';
|
||||
payload?: boolean;
|
||||
}
|
||||
| {
|
||||
key: 'yearRange';
|
||||
payload?: number | [number, number];
|
||||
}
|
||||
| {
|
||||
key: 'faceValue';
|
||||
payload?: number | [number, number] | null;
|
||||
};
|
||||
|
||||
const validFaceValues = [
|
||||
0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 10, 15, 20, 25, 50, 100, 200, 250, 500, 1000
|
||||
];
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const arrayToggle = <T extends string>(array: T[], value: T) => {
|
||||
if (array.includes(value)) {
|
||||
return array.filter(inner => inner !== value);
|
||||
}
|
||||
|
||||
return [...array, value];
|
||||
};
|
||||
|
||||
const reducer = (state: State, { key, payload }: Action) => {
|
||||
switch (key) {
|
||||
case 'currency':
|
||||
return {
|
||||
...state,
|
||||
currencies: arrayToggle(state.currencies, payload)
|
||||
};
|
||||
|
||||
case 'country':
|
||||
return {
|
||||
...state,
|
||||
countries: arrayToggle(state.countries, payload)
|
||||
};
|
||||
|
||||
case 'special':
|
||||
case 'standard':
|
||||
case 'coins':
|
||||
case 'banknotes':
|
||||
return {
|
||||
...state,
|
||||
[key]: !!payload
|
||||
};
|
||||
|
||||
case 'yearRange':
|
||||
return {
|
||||
...state,
|
||||
...(Array.isArray(payload) && { yearRange: payload })
|
||||
};
|
||||
|
||||
case 'faceValue':
|
||||
if (Array.isArray(payload)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return {
|
||||
...state,
|
||||
faceValue: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
faceValue: payload
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const stringifyState = (state: State) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (state.search.length) {
|
||||
searchParams.set('search', state.search);
|
||||
}
|
||||
|
||||
state.currencies.forEach(currency => {
|
||||
searchParams.append('currencies', currency);
|
||||
});
|
||||
|
||||
state.countries.forEach(country => {
|
||||
searchParams.append('countries', country);
|
||||
});
|
||||
|
||||
if (state.faceValue) {
|
||||
searchParams.set('faceValue', String(state.faceValue));
|
||||
}
|
||||
|
||||
if (state.special && !state.standard) {
|
||||
searchParams.set('special', 'yes');
|
||||
}
|
||||
|
||||
if (!state.special && state.standard) {
|
||||
searchParams.set('special', 'no');
|
||||
}
|
||||
|
||||
if (state.coins && !state.banknotes) {
|
||||
searchParams.set('category', 'coins');
|
||||
}
|
||||
|
||||
if (!state.coins && state.banknotes) {
|
||||
searchParams.set('category', 'banknotes');
|
||||
}
|
||||
|
||||
if (state.yearRange[0] !== 1800 || state.yearRange[1] !== currentYear) {
|
||||
searchParams.append('yearRange', String(state.yearRange[0]));
|
||||
searchParams.append('yearRange', String(state.yearRange[1]));
|
||||
}
|
||||
|
||||
return searchParams.toString();
|
||||
};
|
||||
|
||||
const parseArray = (value?: string | string[]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value ? [value] : [];
|
||||
};
|
||||
|
||||
const getDefaultParams = (query: ParsedUrlQuery) => {
|
||||
return {
|
||||
banknotes: !query.category || query.category === 'banknotes',
|
||||
coins: !query.category || query.category === 'coins',
|
||||
countries: parseArray(query.countries),
|
||||
currencies: parseArray(query.currencies),
|
||||
faceValue: validFaceValues.includes(Number(query.faceValue))
|
||||
? (101 * validFaceValues.indexOf(Number(query.faceValue))) / validFaceValues.length
|
||||
: undefined,
|
||||
search: typeof query.search === 'string' ? query.search : '',
|
||||
special: !query.special || query.special === 'yes',
|
||||
standard: !query.special || query.special === 'no',
|
||||
yearRange: (() => {
|
||||
if (Array.isArray(query.yearRange)) {
|
||||
return [
|
||||
Math.max(1800, Math.min(currentYear, Number(query.yearRange[0]))),
|
||||
Math.min(currentYear, Math.max(1800, Number(query.yearRange[1])))
|
||||
];
|
||||
}
|
||||
|
||||
return [1800, currentYear];
|
||||
})() as [number, number]
|
||||
};
|
||||
};
|
||||
|
||||
export const Filters = ({ filterBanknotes, filterTextures, query, ...sidebarProps }: Props) => {
|
||||
const router = useRouter();
|
||||
const setTextureBase = useShowcaseStore(state => state.setTextureBase);
|
||||
const setTextureBump = useShowcaseStore(state => state.setTextureBump);
|
||||
const textureBase = useShowcaseStore(state => state.textureBase);
|
||||
const textureBump = useShowcaseStore(state => state.textureBump);
|
||||
const [state, dispatch] = useReducer(reducer, getDefaultParams(query));
|
||||
|
||||
const [validFaceValue, stringifiedState] = useMemo(() => {
|
||||
const validFaceValue = state.faceValue
|
||||
? validFaceValues[Math.floor((validFaceValues.length * state.faceValue) / 101)]
|
||||
: undefined;
|
||||
|
||||
return [validFaceValue, stringifyState({ ...state, faceValue: validFaceValue })];
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.asPath !== `${router.pathname}?${stringifiedState}`) {
|
||||
router.replace(`${router.pathname}?${stringifiedState}`, undefined, {
|
||||
shallow: true
|
||||
});
|
||||
}
|
||||
}, [router, stringifiedState]);
|
||||
|
||||
return (
|
||||
<Sidebar position={'right'} {...sidebarProps}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{filterTextures && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<MultiToggleFilter
|
||||
baseName={'texture'}
|
||||
items={[
|
||||
{
|
||||
name: 'base',
|
||||
set: payload => setTextureBase(!!payload),
|
||||
value: textureBase
|
||||
},
|
||||
{
|
||||
name: 'bump',
|
||||
set: payload => setTextureBump(!!payload),
|
||||
value: textureBump
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!filterBanknotes && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<MultiToggleFilter
|
||||
baseName={'type'}
|
||||
items={[
|
||||
{
|
||||
name: 'coins',
|
||||
set: payload => dispatch({ key: 'coins', payload }),
|
||||
value: state.coins
|
||||
},
|
||||
{
|
||||
name: 'banknotes',
|
||||
set: payload => dispatch({ key: 'banknotes', payload }),
|
||||
value: state.banknotes
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<MultiSelectCountriesFilter
|
||||
selectedValues={state.countries}
|
||||
toggleValue={payload => dispatch({ key: 'country', payload })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<MultiSelectCurrenciesFilter
|
||||
selectedValues={state.currencies}
|
||||
toggleValue={payload => dispatch({ key: 'currency', payload })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label htmlFor={'faceValue'}>{'Face value'}</label>
|
||||
<InputNumber
|
||||
minFractionDigits={2}
|
||||
onChange={() => null}
|
||||
pt={{
|
||||
input: {
|
||||
root: { style: { pointerEvents: 'none', width: '100%' } }
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'not-allowed', marginBottom: 8, width: '100%' }}
|
||||
value={validFaceValue}
|
||||
/>
|
||||
<Slider
|
||||
name={'faceValue'}
|
||||
onChange={({ value }) => dispatch({ key: 'faceValue', payload: value })}
|
||||
value={state.faceValue}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<MultiToggleFilter
|
||||
baseName={'edition'}
|
||||
items={[
|
||||
{
|
||||
name: 'special',
|
||||
set: payload => dispatch({ key: 'special', payload }),
|
||||
value: state.special
|
||||
},
|
||||
{
|
||||
name: 'standard',
|
||||
set: payload => dispatch({ key: 'standard', payload }),
|
||||
value: state.standard
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label htmlFor={'years'}>{'Years'}</label>
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: '1fr max-content 1fr',
|
||||
marginBottom: 8
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
format={false}
|
||||
onChange={() => null}
|
||||
pt={{
|
||||
input: {
|
||||
root: { style: { pointerEvents: 'none', width: '100%' } }
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
value={state.yearRange[0]}
|
||||
/>
|
||||
{'-'}
|
||||
<InputNumber
|
||||
format={false}
|
||||
onChange={() => null}
|
||||
pt={{
|
||||
input: {
|
||||
root: {
|
||||
style: {
|
||||
pointerEvents: 'none',
|
||||
textAlign: 'right',
|
||||
width: '100%'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
value={state.yearRange[1]}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
max={currentYear}
|
||||
min={1800}
|
||||
name={'years'}
|
||||
onChange={({ value }) => dispatch({ key: 'yearRange', payload: value })}
|
||||
range
|
||||
value={state.yearRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CheckboxGroup } from 'react-aria-components';
|
||||
import { CheckRow } from '@/components/filters/check-row';
|
||||
|
||||
export function InvertedGroup<T extends string>({
|
||||
options,
|
||||
labels,
|
||||
facetCounts,
|
||||
selected,
|
||||
excluded,
|
||||
onChange,
|
||||
}: {
|
||||
options: readonly T[];
|
||||
labels: Record<T, string>;
|
||||
facetCounts: Map<T, number>;
|
||||
selected: readonly T[];
|
||||
// Options forced off + disabled (rendered unchecked, can't be toggled, never appear in onChange's
|
||||
// next value). When non-empty, onChange never collapses to null — the explicit list has to stay
|
||||
// in the URL so the exclusion sticks.
|
||||
excluded?: readonly T[];
|
||||
onChange: (next: T[] | null) => void;
|
||||
}) {
|
||||
const excludedSet = new Set(excluded ?? []);
|
||||
const displayed = (selected.length === 0 ? options : selected).filter((o) => !excludedSet.has(o));
|
||||
|
||||
return (
|
||||
<CheckboxGroup
|
||||
value={[...displayed] as string[]}
|
||||
onChange={(v) => {
|
||||
const next = (v as T[]).filter((o) => !excludedSet.has(o));
|
||||
if (next.length === 0) return;
|
||||
const isAll = excludedSet.size === 0 && next.length === options.length;
|
||||
onChange(isAll ? null : next);
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isLast = displayed.length === 1 && displayed[0] === opt;
|
||||
return (
|
||||
<CheckRow
|
||||
key={opt}
|
||||
value={opt}
|
||||
label={labels[opt]}
|
||||
count={facetCounts.get(opt) ?? 0}
|
||||
isDisabled={isLast || excludedSet.has(opt)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CheckboxGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Category, Edition, ShapeVariant } from '@/filter-types';
|
||||
|
||||
export const CATEGORY_LABELS: Record<Category, string> = {
|
||||
coin: 'Coins',
|
||||
banknote: 'Banknotes',
|
||||
exonumia: 'Exonumia',
|
||||
};
|
||||
|
||||
export const EDITION_LABELS: Record<Edition, string> = {
|
||||
standard: 'Standard',
|
||||
special: 'Special',
|
||||
};
|
||||
|
||||
export const SHAPE_LABELS: Record<ShapeVariant, string> = {
|
||||
round: 'Round',
|
||||
polygon: 'Polygon',
|
||||
'holed-round': 'Holed round',
|
||||
'holed-polygon': 'Holed polygon',
|
||||
'notched-round': 'Flower',
|
||||
'sine-round': 'Wavy',
|
||||
rectangular: 'Rectangular',
|
||||
irregular: 'Irregular',
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Issuer } from '@prisma/client';
|
||||
import { MultiSelect, MultiSelectProps } from 'primereact/multiselect';
|
||||
import { getFlagEmoji } from '@/core/utils/flags';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type Props = MultiSelectProps & {
|
||||
selectedValues: string[];
|
||||
toggleValue: (value: string) => void;
|
||||
};
|
||||
|
||||
const Option = (name: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const country = queryClient.getQueryData<Issuer[]>(['countries'])?.find(country => country.name === name);
|
||||
|
||||
return `${getFlagEmoji(country?.iso)} ${name}`;
|
||||
};
|
||||
|
||||
export const MultiSelectCountriesFilter = ({ selectedValues, toggleValue, ...props }: Props) => {
|
||||
const { data } = useQuery<Issuer[]>({
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/countries');
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
queryKey: ['countries']
|
||||
});
|
||||
|
||||
const countries = useMemo(() => data?.map(({ iso, name }) => `${getFlagEmoji(iso)} ${name}`), [data]);
|
||||
const selectedWithFlags = useMemo(
|
||||
() =>
|
||||
selectedValues.map(
|
||||
name => {
|
||||
const country = data?.find(country => country.name === name);
|
||||
|
||||
return `${getFlagEmoji(country?.iso)} ${name}`;
|
||||
},
|
||||
[data]
|
||||
),
|
||||
[data, selectedValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={'countries'}>{'Countries'}</label>
|
||||
<MultiSelect
|
||||
display={'chip'}
|
||||
filter
|
||||
itemTemplate={Option}
|
||||
maxSelectedLabels={4}
|
||||
name={'countries'}
|
||||
onChange={e => toggleValue(e.selectedOption.split(' ').slice(1).join(' '))}
|
||||
options={countries ?? selectedValues}
|
||||
value={selectedWithFlags}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { MultiSelect, MultiSelectProps } from 'primereact/multiselect';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export type Props = MultiSelectProps & {
|
||||
selectedValues: string[];
|
||||
toggleValue: (value: string) => void;
|
||||
};
|
||||
|
||||
export const MultiSelectCurrenciesFilter = ({ selectedValues, toggleValue, ...props }: Props) => {
|
||||
const { data } = useQuery<string[]>({
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/currencies');
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
queryKey: ['currencies']
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={'currencies'}>{'Currencies'}</label>
|
||||
<MultiSelect
|
||||
display={'chip'}
|
||||
filter
|
||||
maxSelectedLabels={4}
|
||||
name={'currencies'}
|
||||
onChange={e => toggleValue(e.selectedOption)}
|
||||
options={data ?? selectedValues}
|
||||
value={selectedValues}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Button, DialogTrigger, ListBox, ListBoxItem, Popover } from 'react-aria-components';
|
||||
import CheckIcon from './icons/check.svg?react';
|
||||
import ChevronIcon from './icons/chevron.svg?react';
|
||||
|
||||
export type MultiSelectOption<T> = {
|
||||
value: T;
|
||||
label: string;
|
||||
count: number;
|
||||
flag?: string | null;
|
||||
};
|
||||
|
||||
export function MultiSelect<T extends string>({
|
||||
placeholder,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
placeholder: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
value: T[];
|
||||
onChange: (val: T[] | null) => void;
|
||||
}) {
|
||||
const labelById = new Map(options.map((o) => [o.value, o.label]));
|
||||
// Hide URL values that aren't in the current facet so the button label and the dropdown stay
|
||||
// consistent. We don't proactively clean up the URL — any hidden values fall out the next time
|
||||
// the user interacts with this field.
|
||||
const visibleValue = value.filter((v) => labelById.has(v));
|
||||
const display =
|
||||
visibleValue.length === 0
|
||||
? placeholder
|
||||
: visibleValue.length <= 2
|
||||
? visibleValue.map((v) => labelById.get(v) ?? v).join(', ')
|
||||
: `${visibleValue.length} selected`;
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
className={`w-full flex items-center gap-2 px-3 h-9 border border-rule-strong bg-card text-data text-left outline-none data-[hovered]:border-brass data-[focus-visible]:border-brass transition-colors ${visibleValue.length === 0 ? 'text-ink-dim italic' : 'text-ink'}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{display}</span>
|
||||
<ChevronIcon className="text-ink-dim" />
|
||||
</Button>
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
offset={4}
|
||||
className="w-(--trigger-width) max-h-72 overflow-y-auto border border-rule-strong bg-card outline-none data-[entering]:animate-pop-in data-[exiting]:animate-pop-out"
|
||||
>
|
||||
<ListBox
|
||||
selectionMode="multiple"
|
||||
selectedKeys={new Set(visibleValue)}
|
||||
onSelectionChange={(keys) => {
|
||||
if (keys === 'all') return;
|
||||
const arr = Array.from(keys) as T[];
|
||||
onChange(arr.length ? arr : null);
|
||||
}}
|
||||
aria-label={placeholder}
|
||||
items={options}
|
||||
className="outline-none py-1"
|
||||
>
|
||||
{(option) => (
|
||||
<ListBoxItem
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className="group flex items-center gap-2.5 px-3 py-1.5 text-data text-ink cursor-pointer outline-none data-[focused]:bg-page data-[selected]:text-brass"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3 text-brass opacity-0 group-data-[selected]:opacity-100 shrink-0" />
|
||||
{option.flag !== undefined && option.flag !== null && (
|
||||
<img
|
||||
src={`/flags/${option.flag}`}
|
||||
alt=""
|
||||
className="w-5 h-3.5 object-contain shrink-0 opacity-90"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-mini text-ink-faint tnum">{option.count}</span>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
|
||||
type Props = {
|
||||
baseName: string;
|
||||
items: Array<{
|
||||
name: string;
|
||||
set: (value?: boolean) => void;
|
||||
value: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const MultiToggleFilter = ({ baseName, items }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<label>{baseName}</label>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
{items.map(({ name, set, value }) => (
|
||||
<div key={name} style={{ alignItems: 'center', display: 'flex', gap: 8 }}>
|
||||
<Checkbox
|
||||
checked={value}
|
||||
inputId={name}
|
||||
name={baseName}
|
||||
onChange={({ target }) => set(target.checked)}
|
||||
value={name}
|
||||
/>
|
||||
<label htmlFor={name}>{name}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="caps text-mini text-brass mb-3 pb-1.5 border-b border-rule">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { startTransition, use, useState } from 'react';
|
||||
import { Button, Link } from 'react-aria-components';
|
||||
import { fetchPieces } from '@/api';
|
||||
import { FaceValueSlider } from '@/components/filters/face-value-slider';
|
||||
import FiltersIcon from '@/components/filters/icons/filters.svg?react';
|
||||
import { InvertedGroup } from '@/components/filters/inverted-group';
|
||||
import { CATEGORY_LABELS, EDITION_LABELS, SHAPE_LABELS } from '@/components/filters/labels';
|
||||
import { MultiSelect } from '@/components/filters/multi-select';
|
||||
import { Section } from '@/components/filters/section';
|
||||
import { isAnyFilterActive, useFilters } from '@/components/filters/state';
|
||||
import { YearRangeSlider } from '@/components/filters/year-range-slider';
|
||||
import { CATEGORIES, type Category, EDITIONS } from '@/filter-types';
|
||||
|
||||
const TOP_BAR_HEIGHT = 'h-14'; // 56px — sets the offset for the drawer below
|
||||
const navLinkClass =
|
||||
'caps text-mini px-2.5 h-8 flex items-center text-ink-dim outline-none transition-colors data-[hovered]:text-brass data-[pressed]:text-brass-deep data-[focus-visible]:text-brass';
|
||||
|
||||
export function FilterSidebar({
|
||||
other,
|
||||
excludedCategories,
|
||||
}: {
|
||||
other: 'pile' | 'showcase';
|
||||
excludedCategories?: readonly Category[];
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { filters, setState } = useFilters();
|
||||
const { facets } = use(fetchPieces(filters));
|
||||
|
||||
const update = (changes: Parameters<typeof setState>[0]) => {
|
||||
startTransition(() => {
|
||||
void setState(changes);
|
||||
});
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
update({
|
||||
countries: null,
|
||||
currencies: null,
|
||||
face_value: null,
|
||||
categories: null,
|
||||
editions: null,
|
||||
shapes: null,
|
||||
year_min: null,
|
||||
year_max: null,
|
||||
});
|
||||
};
|
||||
|
||||
const faceValueSteps = facets.faceValues.map((f) => f.value);
|
||||
const yearMin = facets.yearRange?.min ?? null;
|
||||
const yearMax = facets.yearRange?.max ?? null;
|
||||
const yearRangeUsable = yearMin !== null && yearMax !== null && yearMin < yearMax;
|
||||
|
||||
const categoryCounts = new Map(facets.categories.map((c) => [c.category, c.count]));
|
||||
const editionCounts = new Map(facets.editions.map((e) => [e.edition, e.count]));
|
||||
|
||||
// Preserve query string so filters survive cross-page nav. Read at render time — the host page
|
||||
// re-renders on nuqs state change, so this stays current.
|
||||
const search = typeof window !== 'undefined' ? window.location.search : '';
|
||||
const otherHref = `/${other}${search}`;
|
||||
const otherLabel = other === 'pile' ? 'Pile' : 'Showcase';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top bar — always visible, same width as the drawer. The drawer slides
|
||||
in below it so the nav stays accessible whether the drawer is open
|
||||
or closed. */}
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-full sm:w-96 ${TOP_BAR_HEIGHT} z-40 border-b border-l border-rule bg-page/85 backdrop-blur-sm flex items-center px-4`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href="/" className={navLinkClass}>
|
||||
home
|
||||
</Link>
|
||||
<span className="text-ink-faint">·</span>
|
||||
<Link href={otherHref} className={navLinkClass}>
|
||||
{otherLabel.toLowerCase()}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
onPress={() => setIsOpen((o) => !o)}
|
||||
aria-label="Toggle filters"
|
||||
aria-expanded={isOpen}
|
||||
className={`caps text-mini flex items-center gap-2 px-2.5 h-8 outline-none transition-colors ${isOpen ? 'text-brass' : 'text-ink-dim data-[hovered]:text-brass'} data-[focus-visible]:text-brass`}
|
||||
>
|
||||
<FiltersIcon />
|
||||
filters
|
||||
{isAnyFilterActive(filters) && (
|
||||
<span className="ml-0.5 w-1.5 h-1.5 rounded-full bg-brass" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drawer — anchored below the top bar, slides in from the right.
|
||||
Plain animated div (no react-aria Modal) so the canvas keeps full
|
||||
pointer events even when the drawer is open. */}
|
||||
<aside
|
||||
aria-label="Filters"
|
||||
aria-hidden={!isOpen}
|
||||
className={`absolute top-14 right-0 bottom-0 w-full sm:w-96 bg-page border-l border-rule z-30 transition-transform duration-250 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-full pointer-events-none'}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-rule">
|
||||
<h2 className="caps text-mini text-ink">filters</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAnyFilterActive(filters) && (
|
||||
<Button
|
||||
onPress={clearAll}
|
||||
className="caps text-mini text-ink-dim data-[hovered]:text-brass outline-none transition-colors"
|
||||
>
|
||||
clear all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => setIsOpen(false)}
|
||||
aria-label="Close filters"
|
||||
className="w-7 h-7 flex items-center justify-center text-ink-dim data-[hovered]:text-brass outline-none transition-colors"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||
{facets.countries.length > 0 && (
|
||||
<Section title="Country">
|
||||
<MultiSelect
|
||||
placeholder="All countries"
|
||||
options={facets.countries.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
count: c.count,
|
||||
flag: c.flag_path,
|
||||
}))}
|
||||
value={filters.countries ?? []}
|
||||
onChange={(v) => update({ countries: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{facets.currencies.length > 0 && (
|
||||
<Section title="Currency">
|
||||
<MultiSelect
|
||||
placeholder="All currencies"
|
||||
options={facets.currencies.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name,
|
||||
count: c.count,
|
||||
}))}
|
||||
value={filters.currencies ?? []}
|
||||
onChange={(v) => update({ currencies: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{faceValueSteps.length > 0 && (
|
||||
<Section title="Face value">
|
||||
<FaceValueSlider
|
||||
steps={faceValueSteps}
|
||||
value={filters.faceValue ?? null}
|
||||
onChange={(v) => update({ face_value: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Type">
|
||||
<InvertedGroup
|
||||
options={CATEGORIES}
|
||||
labels={CATEGORY_LABELS}
|
||||
facetCounts={categoryCounts}
|
||||
selected={filters.categories ?? []}
|
||||
excluded={excludedCategories}
|
||||
onChange={(v) => update({ categories: v })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Edition">
|
||||
<InvertedGroup
|
||||
options={EDITIONS}
|
||||
labels={EDITION_LABELS}
|
||||
facetCounts={editionCounts}
|
||||
selected={filters.editions ?? []}
|
||||
onChange={(v) => update({ editions: v })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{facets.shapes.length > 0 && (
|
||||
<Section title="Shape">
|
||||
<MultiSelect
|
||||
placeholder="All shapes"
|
||||
options={facets.shapes.map((s) => ({
|
||||
value: s.shape,
|
||||
label: SHAPE_LABELS[s.shape],
|
||||
count: s.count,
|
||||
}))}
|
||||
value={filters.shapes ?? []}
|
||||
onChange={(v) => update({ shapes: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{yearRangeUsable && (
|
||||
<Section title="Years">
|
||||
<YearRangeSlider
|
||||
bounds={[yearMin, yearMax]}
|
||||
value={[filters.yearMin ?? yearMin, filters.yearMax ?? yearMax]}
|
||||
active={filters.yearMin !== undefined || filters.yearMax !== undefined}
|
||||
onChange={(min, max) => update({ year_min: min, year_max: max })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsFloat,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryStates,
|
||||
} from 'nuqs';
|
||||
import { startTransition, useMemo } from 'react';
|
||||
import { CATEGORIES, EDITIONS, type PieceFilters, SHAPE_VARIANTS } from '@/filter-types';
|
||||
|
||||
// URL parameter shape:
|
||||
// ?countries=portugal,espagne
|
||||
// ?currencies=Euro,Peseta
|
||||
// ?face_value=1
|
||||
// ?categories=coin,banknote
|
||||
// ?editions=standard,special
|
||||
// ?year_min=1990&year_max=2010
|
||||
const parsers = {
|
||||
countries: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
currencies: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
face_value: parseAsFloat,
|
||||
categories: parseAsArrayOf(parseAsStringLiteral(CATEGORIES)).withDefault([]),
|
||||
editions: parseAsArrayOf(parseAsStringLiteral(EDITIONS)).withDefault([]),
|
||||
shapes: parseAsArrayOf(parseAsStringLiteral(SHAPE_VARIANTS)).withDefault([]),
|
||||
year_min: parseAsInteger,
|
||||
year_max: parseAsInteger,
|
||||
};
|
||||
|
||||
export function useFilters() {
|
||||
// startTransition keeps the previous render alive while the new fetch resolves, so filter changes
|
||||
// don't bounce through the outer Suspense fallback (which would unmount the Canvas and lose the
|
||||
// WebGL context).
|
||||
const [state, setState] = useQueryStates(parsers, { startTransition });
|
||||
|
||||
const filters: PieceFilters = useMemo(
|
||||
() => ({
|
||||
countries: state.countries.length ? state.countries : undefined,
|
||||
currencies: state.currencies.length ? state.currencies : undefined,
|
||||
faceValue: state.face_value ?? undefined,
|
||||
categories: state.categories.length ? state.categories : undefined,
|
||||
editions: state.editions.length ? state.editions : undefined,
|
||||
shapes: state.shapes.length ? state.shapes : undefined,
|
||||
yearMin: state.year_min ?? undefined,
|
||||
yearMax: state.year_max ?? undefined,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return { filters, state, setState };
|
||||
}
|
||||
|
||||
export function isAnyFilterActive(filters: PieceFilters): boolean {
|
||||
return (
|
||||
!!filters.countries?.length ||
|
||||
!!filters.currencies?.length ||
|
||||
filters.faceValue !== undefined ||
|
||||
!!filters.categories?.length ||
|
||||
!!filters.editions?.length ||
|
||||
!!filters.shapes?.length ||
|
||||
filters.yearMin !== undefined ||
|
||||
filters.yearMax !== undefined
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Slider,
|
||||
SliderOutput,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
} from 'react-aria-components';
|
||||
|
||||
export function YearRangeSlider({
|
||||
bounds,
|
||||
value,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
bounds: [number, number];
|
||||
value: [number, number];
|
||||
active: boolean;
|
||||
onChange: (min: number | null, max: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
const [min, max] = v as [number, number];
|
||||
onChange(min === bounds[0] ? null : min, max === bounds[1] ? null : max);
|
||||
}}
|
||||
minValue={bounds[0]}
|
||||
maxValue={bounds[1]}
|
||||
step={1}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label className="text-data tnum text-ink">
|
||||
{value[0]} – {value[1]}
|
||||
</Label>
|
||||
{active && (
|
||||
<Button
|
||||
onPress={() => onChange(null, null)}
|
||||
className="caps text-mini text-ink-dim data-[hovered]:text-brass outline-none transition-colors"
|
||||
>
|
||||
clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<SliderTrack className="relative h-5 flex items-center cursor-pointer">
|
||||
{({ state }) => (
|
||||
<>
|
||||
<div className="absolute left-0 right-0 h-px bg-rule-strong" />
|
||||
<div
|
||||
className="absolute h-px bg-brass"
|
||||
style={{
|
||||
left: `${state.getThumbPercent(0) * 100}%`,
|
||||
right: `${(1 - state.getThumbPercent(1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<SliderThumb
|
||||
index={0}
|
||||
className="block w-3 h-3 bg-brass data-[focus-visible]:bg-ink outline-none transition-colors"
|
||||
/>
|
||||
<SliderThumb
|
||||
index={1}
|
||||
className="block w-3 h-3 bg-brass data-[focus-visible]:bg-ink outline-none transition-colors"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SliderTrack>
|
||||
<SliderOutput className="sr-only" />
|
||||
<div className="flex justify-between text-mini text-ink-faint tnum">
|
||||
<span>{bounds[0]}</span>
|
||||
<span>{bounds[1]}</span>
|
||||
</div>
|
||||
</Slider>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { CoinInstance } from '@/components/coin';
|
||||
import { Mesh } from 'three';
|
||||
import { MeshProps, useFrame } from '@react-three/fiber';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { Suspense, useMemo, useRef } from 'react';
|
||||
|
||||
export const CoinMesh = ({ spawningWidth, ...props }: NumistaType & { spawningWidth: number }) => {
|
||||
const meshRef = useRef<Mesh>(null!);
|
||||
const [meshProps, deltaMults] = useMemo(
|
||||
() => [
|
||||
{
|
||||
position: [
|
||||
4 * spawningWidth * (Math.random() - 0.5),
|
||||
4 * (Math.random() - 0.5) + 20,
|
||||
4 * (Math.random() - 0.5) - 16
|
||||
],
|
||||
rotation: [2 * Math.PI * Math.random(), 2 * Math.PI * Math.random(), 2 * Math.PI * Math.random()]
|
||||
} as MeshProps,
|
||||
{
|
||||
position: [2 * (Math.random() - 0.5), -4 * (Math.random() + 0.5), 2 * (Math.random() - 0.5)],
|
||||
rotation: [2 * Math.random() - 1, 2 * Math.random() - 1, 2 * Math.random() - 1]
|
||||
}
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.id]
|
||||
);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
meshRef.current.rotation.x += delta * deltaMults.rotation[0];
|
||||
meshRef.current.rotation.y += delta * deltaMults.rotation[1];
|
||||
meshRef.current.rotation.z += delta * deltaMults.rotation[2];
|
||||
meshRef.current.position.x += delta * deltaMults.position[0];
|
||||
meshRef.current.position.y += delta * deltaMults.position[1];
|
||||
meshRef.current.position.z += delta * deltaMults.position[2];
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
{...meshProps}
|
||||
scale={[(props.size ?? 16) / 16, (props.thickness ?? 2) / 16, (props.size ?? 16) / 16]}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CoinInstance {...props} />
|
||||
</Suspense>
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,53 +1,50 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { CoinMesh } from './coin-mesh';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import styles from './index.module.css';
|
||||
import { Canvas, useThree } from '@react-three/fiber';
|
||||
import { useMemo } from 'react';
|
||||
import { HailSlot, SPAWN_Y } from '@/components/hail/slot';
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
const totalVisible = 8;
|
||||
const NUM_SLOTS = 10;
|
||||
|
||||
export const Hail = () => {
|
||||
const [coins, setCoins] = useState<NumistaType[]>(Array.from({ length: totalVisible }));
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [spawningWidth, setSpawningWidth] = useState<number>(0);
|
||||
function HailScene({ pool }: { pool: PieceWithIssuer[] }) {
|
||||
const { camera, size } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
const verticalFOV = 75;
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
const verticalFOVRadians = verticalFOV * (Math.PI / 180);
|
||||
const horizontalFOV = 2 * Math.atan(aspect * Math.tan(verticalFOVRadians / 2));
|
||||
setSpawningWidth(8 * Math.tan(horizontalFOV / 2));
|
||||
};
|
||||
// Compute viewport width at z=0 from camera params.
|
||||
const spawnWidth = useMemo(() => {
|
||||
const aspect = size.width / size.height;
|
||||
const fov = 'fov' in camera ? (camera.fov as number) : 50;
|
||||
const distance = camera.position.length();
|
||||
const visibleHeight = 2 * distance * Math.tan((fov * Math.PI) / 360);
|
||||
return visibleHeight * aspect * 1.1;
|
||||
}, [camera, size]);
|
||||
|
||||
updateWidth();
|
||||
|
||||
window.addEventListener('resize', updateWidth);
|
||||
|
||||
return () => window.removeEventListener('resize', updateWidth);
|
||||
}, []);
|
||||
|
||||
useQuery({
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/types/random');
|
||||
const coin = await res.json();
|
||||
setCoins(coins => [...coins.slice(0, activeIndex), coin, ...coins.slice(activeIndex + 1)]);
|
||||
setActiveIndex(index => (index + 1) % totalVisible);
|
||||
|
||||
return coin;
|
||||
},
|
||||
queryKey: ['types/random'],
|
||||
refetchInterval: 2000
|
||||
});
|
||||
// Stagger initial Y above the camera frustum so coins enter from the top, never mid-screen.
|
||||
// Higher slots take longer to fall in — that's intentional, gives the build-up a sense of inertia
|
||||
// rather than a wall of coins on first paint.
|
||||
const initialYs = useMemo(
|
||||
() => Array.from({ length: NUM_SLOTS }, (_, i) => SPAWN_Y + i * 0.05),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Canvas className={styles.canvas}>
|
||||
<ambientLight />
|
||||
<directionalLight position={[0, 1, 1]} />
|
||||
{coins.map((coin, index) => coin && <CoinMesh key={index} spawningWidth={spawningWidth} {...coin} />)}
|
||||
<>
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[0.1, 0.2, 0.1]} intensity={0.8} />
|
||||
<directionalLight position={[-0.1, -0.1, 0.05]} intensity={0.3} color="#a0c0ff" />
|
||||
{initialYs.map((y) => (
|
||||
<HailSlot key={y} pool={pool} spawnWidth={spawnWidth} initialY={y} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hail({ pool }: { pool: PieceWithIssuer[] }) {
|
||||
if (!pool.length) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 -z-10 pointer-events-none">
|
||||
<Canvas camera={{ position: [0, 0, 0.4], fov: 40, near: 0.01, far: 1 }}>
|
||||
<HailScene pool={pool} />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import type { Group } from 'three';
|
||||
import { pickCoin, rand } from '@/components/hail/utils';
|
||||
import { Coin } from '@/components/piece/coin';
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
const FALL_SPEED_MIN = 0.03; // m/sec
|
||||
const FALL_SPEED_MAX = 0.08;
|
||||
const TUMBLE_SPEED = 1.4; // rad/sec, max magnitude (unitless)
|
||||
|
||||
export const SPAWN_Y = 0.22;
|
||||
const DESPAWN_Y = -0.22;
|
||||
|
||||
export function HailSlot({
|
||||
pool,
|
||||
spawnWidth,
|
||||
initialY,
|
||||
}: {
|
||||
pool: PieceWithIssuer[];
|
||||
spawnWidth: number;
|
||||
initialY: number;
|
||||
}) {
|
||||
const ref = useRef<Group>(null);
|
||||
const [coin, setCoin] = useState<PieceWithIssuer>(() => pickCoin(pool));
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const motion = useRef({
|
||||
fallSpeed: rand(FALL_SPEED_MIN, FALL_SPEED_MAX),
|
||||
tx: rand(-TUMBLE_SPEED, TUMBLE_SPEED),
|
||||
ty: rand(-TUMBLE_SPEED, TUMBLE_SPEED),
|
||||
tz: rand(-TUMBLE_SPEED, TUMBLE_SPEED),
|
||||
});
|
||||
|
||||
// coin.id is the trigger for respawn positioning. The effect body branches on mountedRef rather
|
||||
// than reading the id directly, but it must re-run when the slot's coin changes.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: see comment above
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
// First mount uses the staggered initialY (already above the camera frustum); every respawn
|
||||
// after that uses SPAWN_Y + a small jitter so coins never appear in view.
|
||||
const y = mountedRef.current ? SPAWN_Y + rand(0, 0.03) : initialY;
|
||||
ref.current.position.set(rand(-spawnWidth / 2, spawnWidth / 2), y, rand(-0.04, 0));
|
||||
ref.current.rotation.set(rand(0, Math.PI * 2), rand(0, Math.PI * 2), rand(0, Math.PI * 2));
|
||||
mountedRef.current = true;
|
||||
}, [coin.id, initialY, spawnWidth]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const g = ref.current;
|
||||
if (!g) return;
|
||||
|
||||
g.position.y -= motion.current.fallSpeed * delta;
|
||||
g.rotation.x += motion.current.tx * delta;
|
||||
g.rotation.y += motion.current.ty * delta;
|
||||
g.rotation.z += motion.current.tz * delta;
|
||||
|
||||
if (g.position.y < DESPAWN_Y) {
|
||||
motion.current = {
|
||||
fallSpeed: rand(FALL_SPEED_MIN, FALL_SPEED_MAX),
|
||||
tx: rand(-TUMBLE_SPEED, TUMBLE_SPEED),
|
||||
ty: rand(-TUMBLE_SPEED, TUMBLE_SPEED),
|
||||
tz: rand(-TUMBLE_SPEED, TUMBLE_SPEED),
|
||||
};
|
||||
// Position will be reset by the useEffect when coin changes.
|
||||
setCoin(pickCoin(pool));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={ref}>
|
||||
<Suspense fallback={null}>
|
||||
<Coin coin={coin} atlas={256} />
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
export function rand(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
export function pickCoin(pool: PieceWithIssuer[]): PieceWithIssuer {
|
||||
const total = pool.reduce((sum, c) => sum + c.count, 0);
|
||||
let r = Math.random() * total;
|
||||
for (const c of pool) {
|
||||
r -= c.count;
|
||||
if (r < 0) return c;
|
||||
}
|
||||
return pool[pool.length - 1];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Button } from 'primereact/button';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
onOpenDrawer?: () => void;
|
||||
route: 'pile' | 'showcase';
|
||||
};
|
||||
|
||||
export const Header = ({ onOpenDrawer, route }: Props) => {
|
||||
const router = useRouter();
|
||||
const searchParams = router.asPath.split('?')[1] ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<Link className={'p-button p-component'} href={'/'} style={{ gap: 8 }}>
|
||||
<i className={'pi pi-arrow-left'} />
|
||||
</Link>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
{route !== 'pile' && (
|
||||
<Link
|
||||
className={'p-button p-button-secondary p-component'}
|
||||
href={`/pile?${searchParams}`}
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
width: 128
|
||||
}}
|
||||
>
|
||||
{'Pile'}
|
||||
</Link>
|
||||
)}
|
||||
{route !== 'showcase' && (
|
||||
<Link
|
||||
className={'p-button p-button-secondary p-component'}
|
||||
href={`/showcase?${searchParams}`}
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
width: 128
|
||||
}}
|
||||
>
|
||||
{'Showcase'}
|
||||
</Link>
|
||||
)}
|
||||
<Button icon={'pi pi-search'} onClick={onOpenDrawer} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export function Loading() {
|
||||
return (
|
||||
<div className="h-svh flex items-center justify-center bg-page text-ink-dim caps text-mini italic">
|
||||
loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// V^^ fold: creases at 0.25, 0.5, 0.75 of the long axis, with bend angles [+θ, -θ, -θ] (valley,
|
||||
// peak, peak). Working out the cumulative slope so the two endpoints land at the same height gives
|
||||
// heights [0, 0, +h, +h, 0] — flat panel, then a plateau over the middle-right two panels. θ is
|
||||
// small so the displacement reads as "slightly folded" rather than crumpled.
|
||||
const CREASE_BENDS = [+0.12, -0.12, -0.12] as const;
|
||||
const CREASE_FRACTIONS = [0.25, 0.5, 0.75] as const;
|
||||
|
||||
// Side strips have no atlas presence — UVs collapse to a single point and the side material is a
|
||||
// flat dominant-colour fill (set by the caller).
|
||||
const SIDE_UV: [number, number] = [0.5, 0.5];
|
||||
|
||||
// Material group indices on the resulting BufferGeometry. The caller mounts the mesh with
|
||||
// `material={[faceMaterial, sideMaterial]}`.
|
||||
export const BANKNOTE_GROUP_FACES = 0;
|
||||
export const BANKNOTE_GROUP_SIDES = 1;
|
||||
|
||||
type Vec3 = [number, number, number];
|
||||
|
||||
// Heights at the 5 control fractions [0, 0.25, 0.5, 0.75, 1] of the long axis. Math worked out in
|
||||
// the file-level comment.
|
||||
function foldProfile(longAxisLength: number): number[] {
|
||||
const slopes = [0, 0, 0, 0];
|
||||
for (let i = 0; i < CREASE_BENDS.length; i++) {
|
||||
slopes[i + 1] = slopes[i] + CREASE_BENDS[i];
|
||||
}
|
||||
const xs = [0, ...CREASE_FRACTIONS, 1].map((f) => f * longAxisLength);
|
||||
const ys = [0];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ys.push(ys[i] + slopes[i] * (xs[i + 1] - xs[i]));
|
||||
}
|
||||
return ys;
|
||||
}
|
||||
|
||||
// Builds a thin-box geometry for a folded banknote.
|
||||
//
|
||||
// width, height: Numista dims (long/short of the rectangle), scene units thickness: face-to-face
|
||||
// thickness
|
||||
//
|
||||
// Local frame matches the coin (cylinder) convention so it slots into the same PieceStage rotation:
|
||||
// faces perpendicular to local Y (obverse on +Y, reverse on −Y), long axis along local X, short
|
||||
// axis along local Z. Two material groups (faces / sides) — see BANKNOTE_GROUP_* exports.
|
||||
export function buildBanknoteGeometry(
|
||||
width: number,
|
||||
height: number,
|
||||
thickness: number,
|
||||
): THREE.BufferGeometry {
|
||||
const longerIsWidth = width >= height;
|
||||
const longLen = longerIsWidth ? width : height;
|
||||
const shortLen = longerIsWidth ? height : width;
|
||||
|
||||
const yProfile = foldProfile(longLen);
|
||||
const xs = [0, ...CREASE_FRACTIONS, 1].map((f) => f * longLen - longLen / 2);
|
||||
const zs: [number, number] = [-shortLen / 2, shortLen / 2];
|
||||
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
// Vertex layout: obverse face = +Y side, indices 0..9 (5 cols × 2 rows). Reverse face = −Y side,
|
||||
// indices 10..19. Side-strip vertices are duplicated separately so they get their own
|
||||
// normals/UVs.
|
||||
const obvIdx = (col: number, row: number) => col * 2 + row;
|
||||
const revIdx = (col: number, row: number) => 10 + col * 2 + row;
|
||||
|
||||
// Obverse — top half of atlas (V 0..0.5).
|
||||
for (let col = 0; col < 5; col++) {
|
||||
for (let row = 0; row < 2; row++) {
|
||||
positions.push(xs[col], yProfile[col] + thickness / 2, zs[row]);
|
||||
normals.push(0, 1, 0);
|
||||
uvs.push(1 - col / 4, row * 0.5);
|
||||
}
|
||||
}
|
||||
// Reverse — bottom half of atlas (V 0.5..1).
|
||||
for (let col = 0; col < 5; col++) {
|
||||
for (let row = 0; row < 2; row++) {
|
||||
positions.push(xs[col], yProfile[col] - thickness / 2, zs[row]);
|
||||
normals.push(0, -1, 0);
|
||||
uvs.push(col / 4, 0.5 + row * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Faces group: triangulate obverse + reverse face quads. With a/b/c/d laid out at (-x,-z),
|
||||
// (+x,-z), (+x,+z), (-x,+z), the CCW order viewed from +Y is a→d→c→b. (Earlier I had a→b→c which
|
||||
// is CW from +Y — geometric normal ended up −Y while vertex normals said +Y, so RectAreaLight lit
|
||||
// the wrong side.)
|
||||
const facesIndexStart = indices.length;
|
||||
for (let col = 0; col < 4; col++) {
|
||||
const a = obvIdx(col, 0);
|
||||
const b = obvIdx(col + 1, 0);
|
||||
const c = obvIdx(col + 1, 1);
|
||||
const d = obvIdx(col, 1);
|
||||
indices.push(a, d, c, a, c, b);
|
||||
}
|
||||
for (let col = 0; col < 4; col++) {
|
||||
const a = revIdx(col, 0);
|
||||
const b = revIdx(col + 1, 0);
|
||||
const c = revIdx(col + 1, 1);
|
||||
const d = revIdx(col, 1);
|
||||
indices.push(a, b, c, a, c, d);
|
||||
}
|
||||
const facesIndexEnd = indices.length;
|
||||
|
||||
// Side strips: duplicate the face vertices into fresh slots so each strand can carry its own
|
||||
// normal and the collapsed side UV.
|
||||
type StrandSpec = { obvPair: [number, number]; revPair: [number, number]; normal: Vec3 };
|
||||
const strands: StrandSpec[] = [];
|
||||
// Near / far long-axis strands (z = zs[0] and zs[1]): 4 segments each.
|
||||
for (const row of [0, 1] as const) {
|
||||
for (let col = 0; col < 4; col++) {
|
||||
strands.push({
|
||||
obvPair: [obvIdx(col, row), obvIdx(col + 1, row)],
|
||||
revPair: [revIdx(col, row), revIdx(col + 1, row)],
|
||||
normal: row === 0 ? [0, 0, -1] : [0, 0, 1],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Left / right short-axis strands (x = xs[0] and xs[4]): 1 segment each.
|
||||
for (const col of [0, 4] as const) {
|
||||
strands.push({
|
||||
obvPair: [obvIdx(col, 0), obvIdx(col, 1)],
|
||||
revPair: [revIdx(col, 0), revIdx(col, 1)],
|
||||
normal: col === 0 ? [-1, 0, 0] : [1, 0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of strands) {
|
||||
// Quad winding: obv-a → obv-b → rev-b → rev-a, so the outward face matches the strand normal
|
||||
// under both long-axis and short-axis strand orientations.
|
||||
const verts = [s.obvPair[0], s.obvPair[1], s.revPair[1], s.revPair[0]];
|
||||
const baseIdx = positions.length / 3;
|
||||
for (const v of verts) {
|
||||
positions.push(positions[v * 3], positions[v * 3 + 1], positions[v * 3 + 2]);
|
||||
normals.push(...s.normal);
|
||||
uvs.push(...SIDE_UV);
|
||||
}
|
||||
indices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx, baseIdx + 2, baseIdx + 3);
|
||||
}
|
||||
const sidesIndexEnd = indices.length;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
|
||||
geometry.addGroup(facesIndexStart, facesIndexEnd - facesIndexStart, BANKNOTE_GROUP_FACES);
|
||||
geometry.addGroup(facesIndexEnd, sidesIndexEnd - facesIndexEnd, BANKNOTE_GROUP_SIDES);
|
||||
|
||||
// Portrait banknotes (height > width): rotate around the face normal (Y) so the long axis
|
||||
// (originally local X) lands on local Z.
|
||||
if (!longerIsWidth) {
|
||||
geometry.rotateY(Math.PI / 2);
|
||||
}
|
||||
|
||||
// Flip upright.
|
||||
geometry.rotateY(Math.PI);
|
||||
|
||||
geometry.computeBoundingSphere();
|
||||
return geometry;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { AtlasSize } from '@/atlas-layout';
|
||||
import { buildBanknoteGeometry } from '@/components/piece/banknote/geometry';
|
||||
import type { Piece as PieceRow } from '@/db/types';
|
||||
|
||||
// Numista doesn't carry a thickness for banknotes. Dummy value, visible edge-on, invisible head-on.
|
||||
// Mm in pile scene, m in showcase — caller multiplies if needed.
|
||||
const BANKNOTE_THICKNESS_MM = 0.12;
|
||||
|
||||
// Paper-ish PBR profile so banknotes don't pick up coin-style speculars. `color` tints the texture:
|
||||
// at 1.0 the lights (calibrated for the coins' shiny metalness=1) overexpose the diffuse-only
|
||||
// banknote; ~0.5 brings it back into the same visual range without touching the rig.
|
||||
const BANKNOTE_FACE_TINT = 0.5;
|
||||
|
||||
// Banknotes at true scale dwarf the showcase frame — a 154mm note is ~6× a typical coin's diameter.
|
||||
// Shrink so they read comparable.
|
||||
const SHOWCASE_BANKNOTE_SCALE = 1 / 4;
|
||||
|
||||
type Props = {
|
||||
banknote: PieceRow;
|
||||
atlas: AtlasSize;
|
||||
};
|
||||
|
||||
function useBanknoteFaceMaterial(banknote: PieceRow, atlas: AtlasSize): THREE.MeshStandardMaterial {
|
||||
const texture = useTexture(`/atlases/${banknote.id}-${atlas}.webp`);
|
||||
return useMemo(() => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.needsUpdate = true;
|
||||
return new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
color: new THREE.Color(BANKNOTE_FACE_TINT, BANKNOTE_FACE_TINT, BANKNOTE_FACE_TINT),
|
||||
metalness: 0,
|
||||
roughness: 0.85,
|
||||
});
|
||||
}, [texture]);
|
||||
}
|
||||
|
||||
// Banknote side material: flat dominant colour, sourced from the DB row.
|
||||
function useBanknoteSideMaterial(banknote: PieceRow): THREE.MeshStandardMaterial {
|
||||
return useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(banknote.color_hex),
|
||||
metalness: 0,
|
||||
roughness: 0.9,
|
||||
}),
|
||||
[banknote.color_hex],
|
||||
);
|
||||
}
|
||||
|
||||
export function Banknote({ banknote, atlas }: Props) {
|
||||
// Scene unit = 1 metre in showcase; Numista dims are mm, so /1000.
|
||||
const widthM = ((banknote.width ?? 150) / 1000) * SHOWCASE_BANKNOTE_SCALE;
|
||||
const heightM = ((banknote.height ?? 70) / 1000) * SHOWCASE_BANKNOTE_SCALE;
|
||||
const thicknessM = (BANKNOTE_THICKNESS_MM / 1000) * SHOWCASE_BANKNOTE_SCALE;
|
||||
|
||||
const faceMaterial = useBanknoteFaceMaterial(banknote, atlas);
|
||||
const sideMaterial = useBanknoteSideMaterial(banknote);
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildBanknoteGeometry(widthM, heightM, thicknessM),
|
||||
[widthM, heightM, thicknessM],
|
||||
);
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry} material={[faceMaterial, sideMaterial]} castShadow receiveShadow />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as THREE from 'three';
|
||||
import { ATLAS_LAYOUT, type AtlasSize } from '@/atlas-layout';
|
||||
|
||||
export type ExtrudeParams = {
|
||||
// Outer outline in the X–Z plane, CCW viewed from above.
|
||||
outer: THREE.Vector2[];
|
||||
// Optional concentric inner outline (a hole). Must have the SAME length as `outer` and be CCW
|
||||
// (the geometry builder reverses it where needed).
|
||||
inner?: THREE.Vector2[];
|
||||
thickness: number;
|
||||
// Half-diameter used to normalise cap UVs into the unit disc inscribed in the [0,1]² UV square.
|
||||
// Pass the coin's radius (the outer outline's max distance from origin).
|
||||
uvRadius: number;
|
||||
atlas: AtlasSize;
|
||||
};
|
||||
|
||||
// Builds an extruded coin geometry matching `buildCoinGeometry`'s UV conventions on a per-shape
|
||||
// basis (so the same atlas texture renders correctly on polygon prisms, holed annuli, flower
|
||||
// scalloped extrusions, and the rest without per-shape remap logic).
|
||||
//
|
||||
// Layout (cylinder convention, axis along Y):
|
||||
// - Top cap at y = +halfH, normal +Y, samples the obverse tile.
|
||||
// - Bottom cap at y = −halfH, normal −Y, samples the reverse tile.
|
||||
// - Side wall connects them; samples the edge strip.
|
||||
// - If inner outline is provided, an inner side wall (normal pointing toward the central axis) is
|
||||
// added so the hole looks like a hollow tube rather than a flat ring.
|
||||
export function buildExtrudedCoinGeometry(params: ExtrudeParams): THREE.BufferGeometry {
|
||||
const { outer, inner, thickness, uvRadius, atlas } = params;
|
||||
const { tile, height } = ATLAS_LAYOUT[atlas];
|
||||
const tileV = tile / height;
|
||||
const stripV = 1 - tileV;
|
||||
const halfH = thickness / 2;
|
||||
const N = outer.length;
|
||||
|
||||
if (inner && inner.length !== N) {
|
||||
throw new Error(
|
||||
`extrude: inner outline must have same length as outer (got ${inner.length} vs ${N})`,
|
||||
);
|
||||
}
|
||||
|
||||
const positions: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
const pushVertex = (
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
u: number,
|
||||
v: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
nz: number,
|
||||
): number => {
|
||||
const idx = positions.length / 3;
|
||||
positions.push(x, y, z);
|
||||
uvs.push(u, v);
|
||||
normals.push(nx, ny, nz);
|
||||
return idx;
|
||||
};
|
||||
|
||||
// Cap UV. Same recipe as `buildCoinGeometry`:
|
||||
// 1. Normalise the cap point (x, z) into the unit disc inscribed in [0,1]².
|
||||
// 2. For the bottom cap, V-mirror around 0.5 — this is what three.js's CylinderGeometry does
|
||||
// internally for its bottom cap, so the reverse picture reads correctly when viewed from
|
||||
// below.
|
||||
// 3. Rotate 90° within the unit square: (u, v) → (v, 1 − u). Lines up picture-up with the cap's
|
||||
// local −Z direction (= world +Y after the scene's [π/2, 0, 0] rotation).
|
||||
// 4. Map into the obverse half (U ∈ [0, 0.5]) or reverse half (U ∈ [0.5, 1]) of the atlas tile
|
||||
// band, and into V ∈ [stripV, 1].
|
||||
// CylinderGeometry's cap UVs use (cos θ · 0.5 + 0.5, sin θ · 0.5 + 0.5), where its vertex
|
||||
// positions are (R sin θ, halfH, R cos θ). That means the default UV.x is driven by the vertex's
|
||||
// Z coordinate and UV.y by its X coordinate — the opposite of the naive "U = x, V = z". Match the
|
||||
// cylinder convention exactly so the same atlas reads correctly on both shapes; without this the
|
||||
// procedural caps render rotated 90° in opposite directions on the two faces, looking like a
|
||||
// mirror across them.
|
||||
const capUV = (x: number, z: number, isObverse: boolean): [number, number] => {
|
||||
const u = z / (2 * uvRadius) + 0.5;
|
||||
let v = x / (2 * uvRadius) + 0.5;
|
||||
if (!isObverse) v = 1 - v;
|
||||
const uRot = v;
|
||||
const vRot = 1 - u;
|
||||
const tileU = isObverse ? uRot * 0.5 : 0.5 + uRot * 0.5;
|
||||
const tileVOut = stripV + vRot * tileV;
|
||||
return [tileU, tileVOut];
|
||||
};
|
||||
|
||||
// Side-wall UV: U is the perimeter fraction (passed in, computed by the caller because the seam
|
||||
// needs a doubled vertex with U=0 vs U=1), and V is bottom-or-top of the edge strip.
|
||||
const sideUV = (perimeterFraction: number, isTop: boolean): [number, number] => [
|
||||
perimeterFraction,
|
||||
isTop ? stripV : 0,
|
||||
];
|
||||
|
||||
// Outward 2D normal at outline vertex i, computed from the chord direction of the two adjacent
|
||||
// edges. Position-normalised would be wrong for non-radial shapes (diamond corners, flower lobes
|
||||
// between scallops).
|
||||
const outlineNormal = (outline: THREE.Vector2[], i: number): [number, number] => {
|
||||
const len = outline.length;
|
||||
const prev = outline[(i - 1 + len) % len];
|
||||
const next = outline[(i + 1) % len];
|
||||
// Tangent: from previous to next. Outward normal for a CCW-from-above polygon in the X–Z plane
|
||||
// (with Y up) is (tangent.z, -tangent.x).
|
||||
const tx = next.x - prev.x;
|
||||
const tz = next.y - prev.y;
|
||||
const nx = tz;
|
||||
const nz = -tx;
|
||||
const nLen = Math.hypot(nx, nz) || 1;
|
||||
return [nx / nLen, nz / nLen];
|
||||
};
|
||||
|
||||
// ===== Top cap (obverse, y = +halfH) =====
|
||||
const topStart = positions.length / 3;
|
||||
if (!inner) {
|
||||
// Fan from centre.
|
||||
pushVertex(0, halfH, 0, ...capUV(0, 0, true), 0, 1, 0);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = outer[i];
|
||||
pushVertex(p.x, halfH, p.y, ...capUV(p.x, p.y, true), 0, 1, 0);
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
indices.push(topStart, topStart + 1 + i, topStart + 1 + ((i + 1) % N));
|
||||
}
|
||||
} else {
|
||||
// Strip between inner and outer ring (annulus).
|
||||
for (let i = 0; i < N; i++) {
|
||||
const op = outer[i];
|
||||
const ip = inner[i];
|
||||
pushVertex(op.x, halfH, op.y, ...capUV(op.x, op.y, true), 0, 1, 0);
|
||||
pushVertex(ip.x, halfH, ip.y, ...capUV(ip.x, ip.y, true), 0, 1, 0);
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
const o0 = topStart + 2 * i;
|
||||
const i0 = topStart + 2 * i + 1;
|
||||
const o1 = topStart + 2 * ((i + 1) % N);
|
||||
const i1 = topStart + 2 * ((i + 1) % N) + 1;
|
||||
// Two triangles per segment, winding CCW from above so normals point +Y.
|
||||
indices.push(o0, o1, i1);
|
||||
indices.push(o0, i1, i0);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Bottom cap (reverse, y = −halfH) =====
|
||||
const botStart = positions.length / 3;
|
||||
if (!inner) {
|
||||
pushVertex(0, -halfH, 0, ...capUV(0, 0, false), 0, -1, 0);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = outer[i];
|
||||
pushVertex(p.x, -halfH, p.y, ...capUV(p.x, p.y, false), 0, -1, 0);
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
// Reverse winding so normals point −Y.
|
||||
indices.push(botStart, botStart + 1 + ((i + 1) % N), botStart + 1 + i);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const op = outer[i];
|
||||
const ip = inner[i];
|
||||
pushVertex(op.x, -halfH, op.y, ...capUV(op.x, op.y, false), 0, -1, 0);
|
||||
pushVertex(ip.x, -halfH, ip.y, ...capUV(ip.x, ip.y, false), 0, -1, 0);
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
const o0 = botStart + 2 * i;
|
||||
const i0 = botStart + 2 * i + 1;
|
||||
const o1 = botStart + 2 * ((i + 1) % N);
|
||||
const i1 = botStart + 2 * ((i + 1) % N) + 1;
|
||||
// Reverse winding for −Y normal.
|
||||
indices.push(o0, i1, o1);
|
||||
indices.push(o0, i0, i1);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Outer side wall ===== Build with N+1 vertex pairs so the seam (last segment) closes with
|
||||
// U=1 adjacent to U=0 at the same world position. Without the doubled seam vertex, the texture
|
||||
// would interpolate backwards across the wrap.
|
||||
const sideStart = positions.length / 3;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const p = outer[i % N];
|
||||
const [nx, nz] = outlineNormal(outer, i % N);
|
||||
const t = i / N;
|
||||
pushVertex(p.x, halfH, p.y, ...sideUV(t, true), nx, 0, nz);
|
||||
pushVertex(p.x, -halfH, p.y, ...sideUV(t, false), nx, 0, nz);
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
const top0 = sideStart + 2 * i;
|
||||
const bot0 = sideStart + 2 * i + 1;
|
||||
const top1 = sideStart + 2 * (i + 1);
|
||||
const bot1 = sideStart + 2 * (i + 1) + 1;
|
||||
// CCW outline from above → outward face winding is (top0, bot0, bot1) / (top0, bot1, top1) so
|
||||
// the right-hand normal points outward.
|
||||
indices.push(top0, bot0, bot1);
|
||||
indices.push(top0, bot1, top1);
|
||||
}
|
||||
|
||||
// ===== Inner side wall (if there's a hole) ===== The hole's wall faces inward (towards the
|
||||
// central axis), so we flip the outline normal and reverse winding. UVs reuse the edge strip —
|
||||
// the hole wall isn't normally visible in a settled pile or showcase view, but it shouldn't be
|
||||
// untextured either.
|
||||
if (inner) {
|
||||
const innerStart = positions.length / 3;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const p = inner[i % N];
|
||||
// Inner wall normals point INWARD relative to the coin's outline, i.e., away from the hole's
|
||||
// centre into the coin material — which is the opposite of the inner outline's outward
|
||||
// (toward origin) tangent normal. So we negate the outlineNormal.
|
||||
const [nx, nz] = outlineNormal(inner, i % N);
|
||||
const t = i / N;
|
||||
pushVertex(p.x, halfH, p.y, ...sideUV(t, true), -nx, 0, -nz);
|
||||
pushVertex(p.x, -halfH, p.y, ...sideUV(t, false), -nx, 0, -nz);
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
const top0 = innerStart + 2 * i;
|
||||
const bot0 = innerStart + 2 * i + 1;
|
||||
const top1 = innerStart + 2 * (i + 1);
|
||||
const bot1 = innerStart + 2 * (i + 1) + 1;
|
||||
// Reverse winding (compared to outer wall) so inward face is the front.
|
||||
indices.push(top0, bot1, bot0);
|
||||
indices.push(top0, top1, bot1);
|
||||
}
|
||||
}
|
||||
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
geom.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geom.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||
geom.setIndex(indices);
|
||||
return geom;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as THREE from 'three';
|
||||
import { ATLAS_LAYOUT, type AtlasSize } from '@/atlas-layout';
|
||||
|
||||
const DEFAULT_ROUND_SEGMENTS = 32;
|
||||
|
||||
export function buildCoinGeometry(
|
||||
radius: number,
|
||||
thickness: number,
|
||||
atlas: AtlasSize,
|
||||
radialSegments: number = DEFAULT_ROUND_SEGMENTS,
|
||||
): THREE.CylinderGeometry {
|
||||
// Odd-sided prisms render flat-side-down by default if we don't offset; even-sided ones look
|
||||
// natural at thetaStart=0. Matches the old project.
|
||||
const thetaStart = radialSegments % 2 === 0 ? 0 : Math.PI / 2;
|
||||
const geometry = new THREE.CylinderGeometry(
|
||||
radius,
|
||||
radius,
|
||||
thickness,
|
||||
radialSegments,
|
||||
1,
|
||||
false,
|
||||
thetaStart,
|
||||
);
|
||||
const { tile, height } = ATLAS_LAYOUT[atlas];
|
||||
const tileV = tile / height;
|
||||
const stripV = 1 - tileV;
|
||||
|
||||
// CylinderGeometry vertex order:
|
||||
// side: (radialSegments + 1) * 2 top cap: radialSegments (fan centers) + (radialSegments + 1)
|
||||
// (ring) bottom cap: same
|
||||
const sideCount = (radialSegments + 1) * 2;
|
||||
const capCount = 2 * radialSegments + 1;
|
||||
|
||||
const uv = geometry.attributes.uv;
|
||||
const arr = uv.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < sideCount; i++) {
|
||||
arr[i * 2 + 1] = arr[i * 2 + 1] * stripV;
|
||||
}
|
||||
|
||||
// Cap UVs rotated 90° within the tile so picture-up lands on local -Z (= world +Y once the scene
|
||||
// rotates the cylinder to face the camera).
|
||||
for (let i = sideCount; i < sideCount + capCount; i++) {
|
||||
const u = arr[i * 2];
|
||||
const v = arr[i * 2 + 1];
|
||||
arr[i * 2] = v * 0.5;
|
||||
arr[i * 2 + 1] = stripV + (1 - u) * tileV;
|
||||
}
|
||||
|
||||
for (let i = sideCount + capCount; i < sideCount + 2 * capCount; i++) {
|
||||
const u = arr[i * 2];
|
||||
const v = arr[i * 2 + 1];
|
||||
arr[i * 2] = 0.5 + v * 0.5;
|
||||
arr[i * 2 + 1] = stripV + (1 - u) * tileV;
|
||||
}
|
||||
|
||||
uv.needsUpdate = true;
|
||||
geometry.clearGroups();
|
||||
return geometry;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { AtlasSize } from '@/atlas-layout';
|
||||
import { buildExtrudedCoinGeometry } from '@/components/piece/coin/extrude';
|
||||
import { buildCoinGeometry } from '@/components/piece/coin/geometry';
|
||||
import { buildShapeFromVariant, type SpecialShape } from '@/components/piece/coin/shape-variants';
|
||||
import type { Piece as PieceRow } from '@/db/types';
|
||||
|
||||
type Props = {
|
||||
coin: PieceRow;
|
||||
atlas: AtlasSize;
|
||||
};
|
||||
|
||||
// Scene unit = 1 metre. Numista values are in mm, so divide by 1000 at the boundary.
|
||||
function coinDimensions(coin: PieceRow): { radius: number; thickness: number } {
|
||||
return {
|
||||
radius: (coin.diameter ?? 24) / 2 / 1000,
|
||||
thickness: (coin.thickness ?? 2) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCoinMaterial(coin: PieceRow, atlas: AtlasSize): THREE.MeshStandardMaterial {
|
||||
const texture = useTexture(`/atlases/${coin.id}-${atlas}.webp`);
|
||||
|
||||
return useMemo(() => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.needsUpdate = true;
|
||||
return new THREE.MeshStandardMaterial({ map: texture, metalness: 1, roughness: 0.45 });
|
||||
}, [texture]);
|
||||
}
|
||||
|
||||
function NormalCoin({ coin, atlas }: Props) {
|
||||
const { radius, thickness } = coinDimensions(coin);
|
||||
const material = useCoinMaterial(coin, atlas);
|
||||
const geometry = useMemo(
|
||||
() => buildCoinGeometry(radius, thickness, atlas, 32),
|
||||
[radius, thickness, atlas],
|
||||
);
|
||||
return <mesh geometry={geometry} material={material} castShadow receiveShadow />;
|
||||
}
|
||||
|
||||
function SpecialCoin({ coin, atlas, shape }: Props & { shape: SpecialShape }) {
|
||||
const { radius, thickness } = coinDimensions(coin);
|
||||
const material = useCoinMaterial(coin, atlas);
|
||||
const geometry = useMemo(
|
||||
() =>
|
||||
buildExtrudedCoinGeometry({
|
||||
outer: shape.outer,
|
||||
inner: shape.inner,
|
||||
thickness,
|
||||
uvRadius: radius,
|
||||
atlas,
|
||||
}),
|
||||
[shape, thickness, radius, atlas],
|
||||
);
|
||||
return <mesh geometry={geometry} material={material} castShadow receiveShadow />;
|
||||
}
|
||||
|
||||
// Procedural-extrude path (SpecialCoin) for variants with outlines; cylinder path (NormalCoin) for
|
||||
// round / irregular.
|
||||
export function Coin({ coin, atlas }: Props) {
|
||||
const { radius } = coinDimensions(coin);
|
||||
const shape = useMemo(
|
||||
() => buildShapeFromVariant(coin.shape_variant, coin.shape_params, radius),
|
||||
[coin.shape_variant, coin.shape_params, radius],
|
||||
);
|
||||
return shape ? (
|
||||
<SpecialCoin coin={coin} atlas={atlas} shape={shape} />
|
||||
) : (
|
||||
<NormalCoin coin={coin} atlas={atlas} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { Vector2 } from 'three';
|
||||
|
||||
// Minimum effective corner-fillet radius (as a fraction of rMax). A true zero produces a single
|
||||
// hard vertex on the side wall that the extrude builder's smooth-normal averaging round-shades on
|
||||
// the rim, which reads worse than a barely-rounded arc. See `polygonOutline`.
|
||||
const CORNER_ROUNDNESS_FLOOR = 0.01;
|
||||
|
||||
// All outlines live in a 2D plane (Vector2.x = world X, Vector2.y = world Z) at y = ±halfThickness
|
||||
// when extruded. Points are listed counter-clockwise when viewed from above (looking down −Y), so
|
||||
// the right-hand normal points outward from the coin.
|
||||
//
|
||||
// Conventions:
|
||||
// - Outline starts at angle θ = 0 (which is at local +Z, picture-up direction after the scene's
|
||||
// [π/2, 0, 0] rotation), going CCW.
|
||||
// - That puts vertex i at angle θ = i / N · 2π, with x = R·sin(θ), z = R·cos(θ). Matches the
|
||||
// convention `buildCoinGeometry` uses for cylinder cap vertices (three.js's CylinderGeometry
|
||||
// does the same).
|
||||
|
||||
export function circleOutline(radius: number, segments: number): Vector2[] {
|
||||
const points: Vector2[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const theta = (i / segments) * Math.PI * 2;
|
||||
points.push(new Vector2(Math.sin(theta) * radius, Math.cos(theta) * radius));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// Spanish-flower outline: a circle whose radius is modulated by a cosine with `lobes` periods
|
||||
// around the circumference. Smooth scalloping — every point on the perimeter is slightly displaced.
|
||||
// Not quite right for the 20 Euro cent (see `notchedCircleOutline` for that) but kept around for
|
||||
// general gently-lobed shapes.
|
||||
//
|
||||
// segments = total points around the perimeter (should be a multiple of `lobes` so each lobe is
|
||||
// sampled identically).
|
||||
export function flowerOutline(
|
||||
baseRadius: number,
|
||||
scallopAmp: number,
|
||||
lobes: number,
|
||||
segments: number,
|
||||
): Vector2[] {
|
||||
const points: Vector2[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const theta = (i / segments) * Math.PI * 2;
|
||||
const r = baseRadius + scallopAmp * Math.cos(lobes * theta);
|
||||
points.push(new Vector2(Math.sin(theta) * r, Math.cos(theta) * r));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// Regular N-gon whose maximum extent from origin equals `radius`. With all three knobs at zero this
|
||||
// is a sharp regular polygon with vertex 0 at +Z (picture-up). The result is strictly convex for
|
||||
// all parameter combinations in [0, 1]³. The outline is dynamically rescaled so that its farthest
|
||||
// point from origin lands exactly at `radius` regardless of cornerRoundness or edgeRoundness —
|
||||
// without rescaling, cornerRoundness > 0 would shrink the silhouette (the corner-arc apex sits
|
||||
// inside the original vertex circle).
|
||||
//
|
||||
// edgeRoundness ∈ [0, 1]:
|
||||
// Each side is a circular arc through the two adjacent vertices, bulging outward with sagitta =
|
||||
// edgeRoundness · R · (1 − cos(α/2)). At 0 the arc degenerates to the straight chord; at 1 it's
|
||||
// a sector of the circumscribed circle and the overall shape becomes the full circumscribed
|
||||
// circle (adjacent arcs share a tangent at the vertex).
|
||||
//
|
||||
// cornerRoundness ∈ [0, 1]:
|
||||
// A circular fillet of radius r = cornerRoundness · R · sin(α/2) · tan(β/2) is placed at each
|
||||
// vertex, internally tangent to both adjacent edge arcs (or to both adjacent straight chords
|
||||
// when edgeRoundness=0). Floored at CORNER_ROUNDNESS_FLOOR (0.01) — a mathematically sharp
|
||||
// vertex shades worse on the rim than a barely- rounded one (the extrude builder smooths
|
||||
// side-wall normals across vertices, which round-shades a single hard vertex but reads
|
||||
// correctly when there's any actual arc geometry there). No real coin has a perfectly sharp
|
||||
// corner anyway.
|
||||
//
|
||||
// phase: rotation in units of α = 2π/sides. Parity-independent: phase=1
|
||||
// rotates the polygon by a full segment (identity), phase=0.5 puts vertices at original edge
|
||||
// midpoints, etc.
|
||||
//
|
||||
// Convexity guarantee: both the corner fillets and the edge arcs have their centres on the
|
||||
// polygon-interior side of the perimeter (the corner fillet centre at distance d_c from origin
|
||||
// along the vertex-radial; the edge arc centre on the chord-midpoint radial at signed distance d_e
|
||||
// from origin). Because both kinds of arc curve toward those interior centres, the perimeter's
|
||||
// curvature direction is consistent everywhere, and the construction ensures C¹ contact at each
|
||||
// tangent point. No inflection points, no kinks.
|
||||
export function polygonOutline(
|
||||
radius: number,
|
||||
sides: number,
|
||||
cornerRoundness: number,
|
||||
edgeRoundness: number,
|
||||
phase: number,
|
||||
segmentsPerCorner: number,
|
||||
segmentsPerEdge: number,
|
||||
): Vector2[] {
|
||||
const N = sides;
|
||||
const alpha = (Math.PI * 2) / N;
|
||||
const beta = ((N - 2) * Math.PI) / N;
|
||||
const phaseRad = phase * alpha;
|
||||
const cosHalfAlpha = Math.cos(alpha / 2);
|
||||
const sinHalfAlpha = Math.sin(alpha / 2);
|
||||
|
||||
// Every dimensional quantity below is linear in R, so we compute the shape's silhouette in
|
||||
// "unit-R" units first, derive the max-extent factor, then inflate R to compensate. The polygon's
|
||||
// farthest point from origin is the corner-arc apex at distance (dC + rFillet) when rFillet > 0,
|
||||
// otherwise it's the vertex at distance R.
|
||||
const cornerClamped = Math.max(CORNER_ROUNDNESS_FLOOR, Math.min(1, cornerRoundness));
|
||||
const rFilletPerR = cornerClamped * sinHalfAlpha * Math.tan(beta / 2);
|
||||
const sagittaPerR = edgeRoundness * (1 - cosHalfAlpha);
|
||||
const halfChordPerR = sinHalfAlpha;
|
||||
const useEdgeArc = sagittaPerR > 1e-9;
|
||||
const A_ePerR = useEdgeArc
|
||||
? (halfChordPerR * halfChordPerR + sagittaPerR * sagittaPerR) / (2 * sagittaPerR)
|
||||
: 0;
|
||||
const d_ePerR = useEdgeArc
|
||||
? cosHalfAlpha - Math.sqrt(Math.max(0, A_ePerR * A_ePerR - halfChordPerR * halfChordPerR))
|
||||
: 0;
|
||||
let dCPerR: number;
|
||||
if (useEdgeArc) {
|
||||
const disc = (A_ePerR - rFilletPerR) ** 2 - d_ePerR * d_ePerR * sinHalfAlpha * sinHalfAlpha;
|
||||
dCPerR = d_ePerR * cosHalfAlpha + Math.sqrt(Math.max(0, disc));
|
||||
} else {
|
||||
dCPerR = 1 - rFilletPerR / cosHalfAlpha;
|
||||
}
|
||||
|
||||
const extentFactor = rFilletPerR > 0 ? dCPerR + rFilletPerR : 1;
|
||||
const R = radius / extentFactor;
|
||||
|
||||
// Lift the per-R quantities to absolute values for the sampling loops.
|
||||
const A_e = A_ePerR * R;
|
||||
const d_e = d_ePerR * R;
|
||||
const rFillet = rFilletPerR * R;
|
||||
const dC = dCPerR * R;
|
||||
|
||||
// Precompute corner centres + incoming/outgoing tangent points so the edge-sampling loop can
|
||||
// reach across the wrap-around boundary.
|
||||
const cornerCx = new Array<number>(N);
|
||||
const cornerCz = new Array<number>(N);
|
||||
const pInX = new Array<number>(N);
|
||||
const pInZ = new Array<number>(N);
|
||||
const pOutX = new Array<number>(N);
|
||||
const pOutZ = new Array<number>(N);
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const vAngle = i * alpha + phaseRad;
|
||||
const cx = dC * Math.sin(vAngle);
|
||||
const cz = dC * Math.cos(vAngle);
|
||||
cornerCx[i] = cx;
|
||||
cornerCz[i] = cz;
|
||||
|
||||
if (useEdgeArc && rFillet > 0) {
|
||||
// Tangent point on each adjacent edge arc lies on the line from the edge arc centre through
|
||||
// the corner centre, projected out to the edge arc surface (radius A_e from the edge arc
|
||||
// centre).
|
||||
const ce1X = d_e * Math.sin(vAngle - alpha / 2);
|
||||
const ce1Z = d_e * Math.cos(vAngle - alpha / 2);
|
||||
const dx1 = cx - ce1X;
|
||||
const dz1 = cz - ce1Z;
|
||||
const len1 = Math.hypot(dx1, dz1) || 1;
|
||||
pInX[i] = ce1X + (dx1 / len1) * A_e;
|
||||
pInZ[i] = ce1Z + (dz1 / len1) * A_e;
|
||||
|
||||
const ce2X = d_e * Math.sin(vAngle + alpha / 2);
|
||||
const ce2Z = d_e * Math.cos(vAngle + alpha / 2);
|
||||
const dx2 = cx - ce2X;
|
||||
const dz2 = cz - ce2Z;
|
||||
const len2 = Math.hypot(dx2, dz2) || 1;
|
||||
pOutX[i] = ce2X + (dx2 / len2) * A_e;
|
||||
pOutZ[i] = ce2Z + (dz2 / len2) * A_e;
|
||||
} else if (rFillet > 0) {
|
||||
// Straight edges: tangent points on the chord, at angle ±α/2 from the vertex direction
|
||||
// (corner centre + rFillet · chord-perpendicular).
|
||||
pInX[i] = cx + Math.sin(vAngle - alpha / 2) * rFillet;
|
||||
pInZ[i] = cz + Math.cos(vAngle - alpha / 2) * rFillet;
|
||||
pOutX[i] = cx + Math.sin(vAngle + alpha / 2) * rFillet;
|
||||
pOutZ[i] = cz + Math.cos(vAngle + alpha / 2) * rFillet;
|
||||
} else {
|
||||
// Sharp corner: tangent points collapse to the vertex itself (which equals the corner centre
|
||||
// at rFillet=0).
|
||||
pInX[i] = cx;
|
||||
pInZ[i] = cz;
|
||||
pOutX[i] = cx;
|
||||
pOutZ[i] = cz;
|
||||
}
|
||||
}
|
||||
|
||||
const points: Vector2[] = [];
|
||||
|
||||
// A vertex with coinciding incoming/outgoing tangent points has no kink for the fillet to round —
|
||||
// this happens at edgeRoundness=1, where both adjacent edge arcs lie on the circumscribed circle
|
||||
// and share a tangent at the vertex. Skip the corner arc in that case and just emit the vertex
|
||||
// point.
|
||||
const TANGENT_COINCIDE_EPS = 1e-6 * R;
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const cx = cornerCx[i];
|
||||
const cz = cornerCz[i];
|
||||
|
||||
const tangentSpread = Math.hypot(pOutX[i] - pInX[i], pOutZ[i] - pInZ[i]);
|
||||
const hasCornerArc = rFillet > 1e-9 && tangentSpread > TANGENT_COINCIDE_EPS;
|
||||
|
||||
if (hasCornerArc) {
|
||||
const angleIn = Math.atan2(pInX[i] - cx, pInZ[i] - cz);
|
||||
const angleOut = Math.atan2(pOutX[i] - cx, pOutZ[i] - cz);
|
||||
let sweep = angleOut - angleIn;
|
||||
// Normalise to the shorter arc — corner sweep is always less than π.
|
||||
if (sweep > Math.PI) sweep -= 2 * Math.PI;
|
||||
else if (sweep < -Math.PI) sweep += 2 * Math.PI;
|
||||
for (let s = 0; s <= segmentsPerCorner; s++) {
|
||||
const a = angleIn + (s / segmentsPerCorner) * sweep;
|
||||
points.push(new Vector2(cx + Math.sin(a) * rFillet, cz + Math.cos(a) * rFillet));
|
||||
}
|
||||
} else {
|
||||
points.push(new Vector2(pInX[i], pInZ[i]));
|
||||
}
|
||||
|
||||
// Edge from this vertex's outgoing tangent to the next vertex's incoming tangent. Interior
|
||||
// samples only (endpoints already pushed).
|
||||
const next = (i + 1) % N;
|
||||
if (useEdgeArc) {
|
||||
const eMidAngle = i * alpha + phaseRad + alpha / 2;
|
||||
const ceX = d_e * Math.sin(eMidAngle);
|
||||
const ceZ = d_e * Math.cos(eMidAngle);
|
||||
const startAngle = Math.atan2(pOutX[i] - ceX, pOutZ[i] - ceZ);
|
||||
const endAngle = Math.atan2(pInX[next] - ceX, pInZ[next] - ceZ);
|
||||
let sweep = endAngle - startAngle;
|
||||
if (sweep > Math.PI) sweep -= 2 * Math.PI;
|
||||
else if (sweep < -Math.PI) sweep += 2 * Math.PI;
|
||||
for (let p = 1; p < segmentsPerEdge; p++) {
|
||||
const a = startAngle + (p / segmentsPerEdge) * sweep;
|
||||
points.push(new Vector2(ceX + Math.sin(a) * A_e, ceZ + Math.cos(a) * A_e));
|
||||
}
|
||||
} else {
|
||||
for (let p = 1; p < segmentsPerEdge; p++) {
|
||||
const u = p / segmentsPerEdge;
|
||||
points.push(
|
||||
new Vector2(
|
||||
pOutX[i] + u * (pInX[next] - pOutX[i]),
|
||||
pOutZ[i] + u * (pInZ[next] - pOutZ[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
// Circle whose radius varies sinusoidally around the perimeter — the shape of e.g. the Bahamas 10c.
|
||||
// Max radius equals `radius`, min radius equals `radius · (1 − 2·amplitude)`. `amplitude` is the
|
||||
// peak-to-mean displacement as a fraction of `radius`, so the trough sits at `2·amplitude` below
|
||||
// the peak. `phase` rotates the wave in units of one peak period (parity-independent, same
|
||||
// convention as `polygonOutline`): phase=0 puts a peak at θ=0 (+Z), phase=0.5 puts a trough there,
|
||||
// phase=1 is the identity.
|
||||
//
|
||||
// R(θ) = radius · ((1 − amplitude) + amplitude · sin(peaks · θ − 2π · phase))
|
||||
//
|
||||
// Pass amplitude < 0.5; in practice 0.03–0.08 is right for visible-but- gentle waviness.
|
||||
export function sineRoundOutline(
|
||||
radius: number,
|
||||
amplitude: number,
|
||||
peaks: number,
|
||||
phase: number,
|
||||
segments: number,
|
||||
): Vector2[] {
|
||||
const points: Vector2[] = [];
|
||||
const phaseShift = Math.PI * 2 * phase;
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const theta = (i / segments) * Math.PI * 2;
|
||||
const r = radius * (1 - amplitude + amplitude * Math.sin(peaks * theta - phaseShift));
|
||||
points.push(new Vector2(Math.sin(theta) * r, Math.cos(theta) * r));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// Circle with N narrow, localized indents — the actual shape of the 20 Euro cent's "Spanish
|
||||
// flower". Almost the entire perimeter is at `baseRadius`; at N evenly-spaced angles the outline
|
||||
// dips inward to (1 − notchDepth) · baseRadius. Higher `sharpness` makes each notch narrower and
|
||||
// more cusp-like; ~6–10 reads as a definite dip without being a hard corner.
|
||||
//
|
||||
// R(θ) = baseRadius · (1 − notchDepth · max(0, cos(N·θ))^sharpness)
|
||||
//
|
||||
// `segments` should be high enough to sample each notch smoothly. With `sharpness = 8` the notch is
|
||||
// significant within ~15° of its centre, so budget ~30+ segments per notch.
|
||||
export function notchedCircleOutline(
|
||||
baseRadius: number,
|
||||
notchDepth: number,
|
||||
notches: number,
|
||||
segments: number,
|
||||
sharpness = 8,
|
||||
): Vector2[] {
|
||||
const points: Vector2[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const theta = (i / segments) * Math.PI * 2;
|
||||
const c = Math.cos(notches * theta);
|
||||
const indent = c > 0 ? c ** sharpness : 0;
|
||||
const r = baseRadius * (1 - notchDepth * indent);
|
||||
points.push(new Vector2(Math.sin(theta) * r, Math.cos(theta) * r));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Vector2 } from 'three';
|
||||
import {
|
||||
circleOutline,
|
||||
notchedCircleOutline,
|
||||
polygonOutline,
|
||||
sineRoundOutline,
|
||||
} from '@/components/piece/coin/outlines';
|
||||
import type { ShapeParams, ShapeVariant } from '@/db/types';
|
||||
|
||||
export type SpecialShape = {
|
||||
outer: Vector2[];
|
||||
inner?: Vector2[];
|
||||
};
|
||||
|
||||
const ROUND_SEGMENTS = 64;
|
||||
const NOTCHED_SEGMENTS = 280;
|
||||
const SINE_SEGMENTS = 280;
|
||||
const POLYGON_SEGMENTS_PER_CORNER = 12;
|
||||
const POLYGON_SEGMENTS_PER_EDGE = 12;
|
||||
|
||||
// Parse `shape_params` for a specific variant. Returns null if the variant is parameterless (round
|
||||
// / irregular), or if the JSON is missing/invalid.
|
||||
function parseParams<V extends ShapeVariant>(
|
||||
variant: V,
|
||||
paramsJson: string | null,
|
||||
): ShapeParams[V] | null {
|
||||
if (variant === 'round' || variant === 'irregular' || variant === 'rectangular') return null;
|
||||
if (paramsJson === null) return null;
|
||||
try {
|
||||
return JSON.parse(paramsJson) as ShapeParams[V];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a polygon outline from the parsed params object. Shared between `polygon` and
|
||||
// `holed-polygon` since both want the same outer shape.
|
||||
function polygonFromParams(
|
||||
p: { sides: number; corner_roundness?: number; edge_roundness?: number; phase?: number },
|
||||
radius: number,
|
||||
): Vector2[] {
|
||||
return polygonOutline(
|
||||
radius,
|
||||
Math.max(3, p.sides),
|
||||
p.corner_roundness ?? 0,
|
||||
p.edge_roundness ?? 0,
|
||||
p.phase ?? 0,
|
||||
POLYGON_SEGMENTS_PER_CORNER,
|
||||
POLYGON_SEGMENTS_PER_EDGE,
|
||||
);
|
||||
}
|
||||
|
||||
// Builds the outline(s) for an extruded coin from the variant + params. Returns null when the
|
||||
// variant should render as a plain cylinder (round / irregular) — the caller (NormalCoin) takes
|
||||
// that path via `buildCoinGeometry`.
|
||||
export function buildShapeFromVariant(
|
||||
variant: ShapeVariant,
|
||||
paramsJson: string | null,
|
||||
radius: number,
|
||||
): SpecialShape | null {
|
||||
switch (variant) {
|
||||
case 'round':
|
||||
case 'irregular':
|
||||
// Handled by NormalCoin's cylinder geometry.
|
||||
return null;
|
||||
|
||||
case 'rectangular':
|
||||
// Banknote-only; rendered via category dispatch elsewhere, not this outline pipeline.
|
||||
return null;
|
||||
|
||||
case 'polygon': {
|
||||
const p = parseParams('polygon', paramsJson);
|
||||
if (!p) return null;
|
||||
return { outer: polygonFromParams(p, radius) };
|
||||
}
|
||||
|
||||
case 'notched-round': {
|
||||
const p = parseParams('notched-round', paramsJson);
|
||||
if (!p) return null;
|
||||
return {
|
||||
outer: notchedCircleOutline(
|
||||
radius,
|
||||
p.notch_depth,
|
||||
p.notches,
|
||||
NOTCHED_SEGMENTS,
|
||||
p.sharpness,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
case 'sine-round': {
|
||||
const p = parseParams('sine-round', paramsJson);
|
||||
if (!p) return null;
|
||||
return {
|
||||
outer: sineRoundOutline(radius, p.amplitude, p.peaks, p.phase ?? 0, SINE_SEGMENTS),
|
||||
};
|
||||
}
|
||||
|
||||
case 'holed-round': {
|
||||
const p = parseParams('holed-round', paramsJson);
|
||||
if (!p) return null;
|
||||
return {
|
||||
outer: circleOutline(radius, ROUND_SEGMENTS),
|
||||
inner: circleOutline(radius * p.hole_ratio, ROUND_SEGMENTS),
|
||||
};
|
||||
}
|
||||
|
||||
case 'holed-polygon': {
|
||||
const p = parseParams('holed-polygon', paramsJson);
|
||||
if (!p) return null;
|
||||
const outer = polygonFromParams(p, radius);
|
||||
// Inner hole stays circular (overwhelmingly the case for Numista's "Holed N-sided" coins).
|
||||
// Resample to match the outer point count so the extrude builder can pair vertices across the
|
||||
// annulus.
|
||||
return { outer, inner: circleOutline(radius * p.hole_ratio, outer.length) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AtlasSize } from '@/atlas-layout';
|
||||
import { Banknote } from '@/components/piece/banknote';
|
||||
import { Coin } from '@/components/piece/coin';
|
||||
import type { Piece as PieceRow } from '@/db/types';
|
||||
|
||||
type Props = {
|
||||
piece: PieceRow;
|
||||
atlas: AtlasSize;
|
||||
};
|
||||
|
||||
// Dispatch a piece (coin / banknote / exonumia) to the right renderer based on its category. Used
|
||||
// by showcase, the only surface that renders both coins and banknotes; hail and pile use `<Coin>`
|
||||
// directly.
|
||||
export function Piece({ piece, atlas }: Props) {
|
||||
if (piece.category === 'banknote') {
|
||||
return <Banknote banknote={piece} atlas={atlas} />;
|
||||
}
|
||||
return <Coin coin={piece} atlas={atlas} />;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { CoinInstance } from '../coin';
|
||||
import { InstancedMesh } from 'three';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { useCylinder } from '@react-three/cannon';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const CoinInstances = (coinProps: NumistaType) => {
|
||||
const didInit = useRef(false);
|
||||
const count = coinProps.count ?? 1;
|
||||
const size = coinProps.size ?? 16;
|
||||
const thickness = coinProps.thickness ?? 2;
|
||||
const weight = coinProps.weight ?? 4;
|
||||
|
||||
const [ref, { at }] = useCylinder(
|
||||
() => ({
|
||||
args: [(1 + size) / 16, (1 + size) / 16, thickness / 16, 8],
|
||||
mass: weight,
|
||||
position: [0, -3, 0]
|
||||
}),
|
||||
useRef<InstancedMesh>(null)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInit.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
didInit.current = true;
|
||||
Array.from({ length: count }).forEach((_, index) => {
|
||||
at(index).position.set(8 * (Math.random() * 2 - 1), Math.random() * 8 + 16, 8 * (Math.random() * 2 - 1));
|
||||
at(index).rotation.set(
|
||||
Math.PI * (Math.random() * 2 - 1),
|
||||
Math.PI * (Math.random() * 2 - 1),
|
||||
Math.PI * (Math.random() * 2 - 1)
|
||||
);
|
||||
|
||||
at(index).scaleOverride([size / 16, thickness / 16, size / 16]);
|
||||
});
|
||||
}, [at, count, size, thickness]);
|
||||
|
||||
return (
|
||||
<instancedMesh args={[undefined, undefined, count]} castShadow receiveShadow ref={ref}>
|
||||
<CoinInstance {...coinProps} />
|
||||
</instancedMesh>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
CylinderCollider,
|
||||
InstancedRigidBodies,
|
||||
type InstancedRigidBodyProps,
|
||||
} from '@react-three/rapier';
|
||||
import { useMemo } from 'react';
|
||||
import { useCoinMaterial } from '@/components/piece/coin';
|
||||
import { buildExtrudedCoinGeometry } from '@/components/piece/coin/extrude';
|
||||
import { buildCoinGeometry } from '@/components/piece/coin/geometry';
|
||||
import { buildShapeFromVariant, type SpecialShape } from '@/components/piece/coin/shape-variants';
|
||||
import type { Piece as PieceRow } from '@/db/types';
|
||||
|
||||
type Props = {
|
||||
coin: PieceRow;
|
||||
count: number;
|
||||
};
|
||||
|
||||
// Pile scene uses 1 unit = 1 mm. Numista values land in directly with no conversion; gravity,
|
||||
// positions, and Rapier's internal tolerances all sit at numerically friendly magnitudes (coin ~24
|
||||
// units wide, not 0.024).
|
||||
const DROP_X = 80;
|
||||
const DROP_Z = 80;
|
||||
// Above the camera's ~135 mm visible top, so coins enter from offscreen.
|
||||
const DROP_Y_MIN = 160;
|
||||
const DROP_Y_MAX = 360;
|
||||
|
||||
function coinDimensions(coin: PieceRow): { radius: number; thickness: number } {
|
||||
return {
|
||||
radius: (coin.diameter ?? 24) / 2,
|
||||
thickness: coin.thickness ?? 2,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInstances(coinId: number, count: number): InstancedRigidBodyProps[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
key: `${coinId}-${i}`,
|
||||
position: [
|
||||
(Math.random() - 0.5) * 2 * DROP_X,
|
||||
DROP_Y_MIN + Math.random() * (DROP_Y_MAX - DROP_Y_MIN),
|
||||
(Math.random() - 0.5) * 2 * DROP_Z,
|
||||
],
|
||||
rotation: [
|
||||
Math.random() * Math.PI * 2,
|
||||
Math.random() * Math.PI * 2,
|
||||
Math.random() * Math.PI * 2,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// Shared rigid-body wrapper. Always uses a `CylinderCollider` matching the coin's diameter and
|
||||
// thickness, regardless of the visual geometry. This is approximate for GLTF specials (flower,
|
||||
// holed, diamond-square) but acceptable at this scale; a custom mesh collider is the next
|
||||
// refinement if pile dynamics ever look wrong on a non-cylinder.
|
||||
function CoinBodies({
|
||||
coin,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
coin: PieceRow;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { radius, thickness } = coinDimensions(coin);
|
||||
|
||||
// Density in g/mm³, consistent with our mm-scale gravity (mm/s²) and Numista weight (g).
|
||||
const density = useMemo(() => {
|
||||
const volume = Math.PI * radius * radius * thickness; // mm³
|
||||
return (coin.weight ?? 8) / volume;
|
||||
}, [coin.weight, radius, thickness]);
|
||||
|
||||
const instances = useMemo(() => makeInstances(coin.id, count), [coin.id, count]);
|
||||
|
||||
return (
|
||||
<InstancedRigidBodies
|
||||
instances={instances}
|
||||
colliders={false}
|
||||
softCcdPrediction={thickness * 2}
|
||||
additionalSolverIterations={8}
|
||||
linearDamping={0.7}
|
||||
angularDamping={2}
|
||||
colliderNodes={[
|
||||
<CylinderCollider
|
||||
key="c"
|
||||
args={[thickness / 2, radius]}
|
||||
density={density}
|
||||
restitution={0}
|
||||
friction={1}
|
||||
contactSkin={0.2}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</InstancedRigidBodies>
|
||||
);
|
||||
}
|
||||
|
||||
function NormalPileCoin({ coin, count }: Props) {
|
||||
const { radius, thickness } = coinDimensions(coin);
|
||||
const material = useCoinMaterial(coin, 256);
|
||||
const geometry = useMemo(
|
||||
() => buildCoinGeometry(radius, thickness, 256, 32),
|
||||
[radius, thickness],
|
||||
);
|
||||
|
||||
return (
|
||||
<CoinBodies coin={coin} count={count}>
|
||||
<instancedMesh args={[geometry, material, count]} castShadow receiveShadow />
|
||||
</CoinBodies>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialPileCoin({ coin, count, shape }: Props & { shape: SpecialShape }) {
|
||||
const { radius, thickness } = coinDimensions(coin);
|
||||
const material = useCoinMaterial(coin, 256);
|
||||
const geometry = useMemo(
|
||||
() =>
|
||||
buildExtrudedCoinGeometry({
|
||||
outer: shape.outer,
|
||||
inner: shape.inner,
|
||||
thickness,
|
||||
uvRadius: radius,
|
||||
atlas: 256,
|
||||
}),
|
||||
[shape, thickness, radius],
|
||||
);
|
||||
|
||||
return (
|
||||
<CoinBodies coin={coin} count={count}>
|
||||
<instancedMesh args={[geometry, material, count]} castShadow receiveShadow />
|
||||
</CoinBodies>
|
||||
);
|
||||
}
|
||||
|
||||
export function PileCoin({ coin, count }: Props) {
|
||||
const { radius } = coinDimensions(coin);
|
||||
const shape = useMemo(
|
||||
() => buildShapeFromVariant(coin.shape_variant, coin.shape_params, radius),
|
||||
[coin.shape_variant, coin.shape_params, radius],
|
||||
);
|
||||
return shape ? (
|
||||
<SpecialPileCoin coin={coin} count={count} shape={shape} />
|
||||
) : (
|
||||
<NormalPileCoin coin={coin} count={count} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useRapier } from '@react-three/rapier';
|
||||
import { useRef } from 'react';
|
||||
|
||||
// Rapier's island sleep needs every body in a contact island below threshold for 2 s. In a 200-coin
|
||||
// pile that's one big island, so a single occasional impulse jitter from any coin resets the whole
|
||||
// pile's timer. Park bodies individually after a much shorter quiet window; rapier still wakes them
|
||||
// on real contact. Rollers have linvel ≈ radius × angvel, so a rolling coin has high linear
|
||||
// velocity even at moderate angvel; the low LIN gate excludes them. Wobblers (in-place jitter) have
|
||||
// near-zero linear motion, so a high ANG gate scoops them up without sweeping up rollers.
|
||||
const QUIET_SECONDS = 0.3;
|
||||
const QUIET_LIN = 3; // mm/s
|
||||
const QUIET_ANG = 3; // rad/s
|
||||
|
||||
export function CustomSleep() {
|
||||
const { world } = useRapier();
|
||||
const timers = useRef(new Map<number, number>());
|
||||
// Bodies we've already parked once. Used to skip the QUIET_SECONDS timer when rapier auto-wakes
|
||||
// them from incidental contact — if they're still quiet, we know they were settled and can
|
||||
// re-park immediately.
|
||||
const settled = useRef(new Set<number>());
|
||||
|
||||
useFrame((_, dt) => {
|
||||
world.forEachRigidBody((body) => {
|
||||
if (!body.isDynamic()) return;
|
||||
|
||||
if (body.isSleeping()) {
|
||||
timers.current.delete(body.handle);
|
||||
return;
|
||||
}
|
||||
|
||||
const lv = body.linvel();
|
||||
const av = body.angvel();
|
||||
const linMag = Math.hypot(lv.x, lv.y, lv.z);
|
||||
const angMag = Math.hypot(av.x, av.y, av.z);
|
||||
const quiet = linMag < QUIET_LIN && angMag < QUIET_ANG;
|
||||
|
||||
if (!quiet) {
|
||||
// Real motion — drop both the timer and the settled mark.
|
||||
timers.current.delete(body.handle);
|
||||
settled.current.delete(body.handle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Previously settled and woken without real motion — re-park.
|
||||
if (settled.current.has(body.handle)) {
|
||||
body.sleep();
|
||||
timers.current.delete(body.handle);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = (timers.current.get(body.handle) ?? 0) + dt;
|
||||
if (t >= QUIET_SECONDS) {
|
||||
body.sleep();
|
||||
settled.current.add(body.handle);
|
||||
timers.current.delete(body.handle);
|
||||
} else {
|
||||
timers.current.set(body.handle, t);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CuboidCollider, RigidBody } from '@react-three/rapier';
|
||||
|
||||
export function Floor() {
|
||||
return (
|
||||
<RigidBody type="fixed" colliders={false}>
|
||||
<CuboidCollider args={[1000, 1, 1000]} position={[0, -1, 0]} friction={2} />
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<circleGeometry args={[500, 64]} />
|
||||
<meshStandardMaterial color="#1a1a1a" roughness={0.95} metalness={0} />
|
||||
</mesh>
|
||||
</RigidBody>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.canvas {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Canvas, SpotLightProps } from '@react-three/fiber';
|
||||
import { CoinInstances } from './coin-instances';
|
||||
import { Mesh, Vector3 } from 'three';
|
||||
import { Message } from 'primereact/message';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { Physics, PlaneProps, usePlane } from '@react-three/cannon';
|
||||
import { StageLightModel } from './stage-light-model';
|
||||
import { Suspense, useMemo, useRef } from 'react';
|
||||
import { useNumistaTypes } from '@/core/hooks/use-types';
|
||||
import { useShowcaseStore } from '@/components/showcase/store';
|
||||
import styles from './index.module.css';
|
||||
|
||||
const Plane = (props: PlaneProps) => {
|
||||
const [ref] = usePlane(() => ({ ...props }), useRef<Mesh>(null));
|
||||
|
||||
return (
|
||||
<mesh receiveShadow ref={ref}>
|
||||
<circleGeometry args={[128]} />
|
||||
<meshStandardMaterial color={'#222'} metalness={0} roughness={1} />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
const StageLight = (
|
||||
props: Omit<SpotLightProps, 'position'> & {
|
||||
position: readonly [x: number, y: number, z: number];
|
||||
}
|
||||
) => {
|
||||
const pos = new Vector3(...props.position);
|
||||
|
||||
return (
|
||||
<>
|
||||
<spotLight {...props} castShadow penumbra={1} />
|
||||
<StageLightModel position={pos} />
|
||||
<mesh position={[pos.x + Math.abs(pos.x) / pos.x, (pos.y - 2) / 2, pos.z + Math.abs(pos.z) / pos.z]}>
|
||||
<cylinderGeometry args={[1 / 3, 1 / 3, pos.y + 2, 6]} />
|
||||
<meshBasicMaterial color={'black'} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pile = () => {
|
||||
const hasTextureBase = useShowcaseStore(state => state.textureBase);
|
||||
const hasTextureBump = useShowcaseStore(state => state.textureBump);
|
||||
const coins = useNumistaTypes({ filterBanknotes: true });
|
||||
const count = useMemo(() => {
|
||||
return coins?.reduce((total, coin) => total + (coin.count ?? 1), 0) ?? 0;
|
||||
}, [coins]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{count > 200 && (
|
||||
<div
|
||||
style={{
|
||||
inset: '8px 64px auto',
|
||||
margin: 'auto',
|
||||
maxWidth: 'max-content',
|
||||
position: 'absolute',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<Message
|
||||
severity={'error'}
|
||||
style={{
|
||||
margin: 'auto',
|
||||
maxWidth: 'calc(100vw - 128px)',
|
||||
width: 'max-content'
|
||||
}}
|
||||
text={
|
||||
<>
|
||||
{`Existem ${count} moedas que cumprem estes requisitos.`}
|
||||
<br /> {'Faz uma pesquisa mais específica.'}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Canvas camera={{ position: [0, 16, 16] }} className={styles.canvas} shadows>
|
||||
<StageLight position={[32, 16, 32]} />
|
||||
<StageLight position={[32, 16, -32]} />
|
||||
<StageLight position={[-32, 16, 32]} />
|
||||
<StageLight position={[-32, 16, -32]} />
|
||||
<Physics gravity={[0, -20, 0]}>
|
||||
<Plane position={[0, -2, 0]} rotation={[-Math.PI / 2, 0, 0]} />
|
||||
{count < 200 &&
|
||||
coins?.map(coinProps => (
|
||||
<Suspense fallback={null} key={`${coinProps.id}-${hasTextureBase}-${hasTextureBump}`}>
|
||||
<CoinInstances {...coinProps} />
|
||||
</Suspense>
|
||||
))}
|
||||
</Physics>
|
||||
|
||||
<OrbitControls enablePan={false} maxDistance={96} maxPolarAngle={Math.PI / 2} minDistance={4} />
|
||||
</Canvas>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ContactShadows, OrbitControls, PerspectiveCamera } from '@react-three/drei';
|
||||
import { Physics } from '@react-three/rapier';
|
||||
import { Suspense } from 'react';
|
||||
import { PileCoin } from '@/components/pile/coin';
|
||||
import { CustomSleep } from '@/components/pile/custom-sleep';
|
||||
import { Floor } from '@/components/pile/floor';
|
||||
import { StageLight } from '@/components/pile/stage-light';
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
// Pile scene uses 1 unit = 1 mm. Real g would be 9810 mm/s²; we use much less so the entire fall is
|
||||
// visible inside the camera's ~190 mm tall view.
|
||||
const GRAVITY: [number, number, number] = [0, -300, 0];
|
||||
|
||||
// Double the default 1/60 rate. More solver work per real second, the canonical fix for thin-disk
|
||||
// stack chatter (Blender's coin demo uses 1/240 + 50 iters).
|
||||
const TIME_STEP = 1 / 120;
|
||||
|
||||
export function PileScene({ coins }: { coins: PieceWithIssuer[] }) {
|
||||
return (
|
||||
<>
|
||||
<PerspectiveCamera makeDefault position={[0, 180, 300]} fov={35} near={1} far={2000} />
|
||||
<ambientLight intensity={0.05} />
|
||||
<StageLight position={[150, 250, 150]} />
|
||||
<StageLight position={[150, 250, -150]} />
|
||||
<StageLight position={[-150, 250, 150]} />
|
||||
<StageLight position={[-150, 250, -150]} />
|
||||
|
||||
{/*
|
||||
Local Suspense around the suspending subtree (rapier's WASM init and
|
||||
the per-coin useTexture loaders). Without this, their suspensions bubble
|
||||
up to the outer Suspense in pile-entry.tsx, which unmounts Pile entirely
|
||||
and disposes the WebGL context — fatal in dev under StrictMode's
|
||||
mount/unmount/remount cycle.
|
||||
*/}
|
||||
<Suspense fallback={null}>
|
||||
<Physics
|
||||
gravity={GRAVITY}
|
||||
timeStep={TIME_STEP}
|
||||
lengthUnit={24}
|
||||
numSolverIterations={8}
|
||||
numInternalPgsIterations={2}
|
||||
contactNaturalFrequency={60}
|
||||
>
|
||||
<Floor />
|
||||
<CustomSleep />
|
||||
{coins.map((coin) => (
|
||||
<PileCoin key={coin.id} coin={coin} count={coin.count} />
|
||||
))}
|
||||
</Physics>
|
||||
</Suspense>
|
||||
|
||||
<ContactShadows position={[0, 0.01, 0]} opacity={0.6} scale={600} blur={2.5} far={200} />
|
||||
|
||||
<OrbitControls
|
||||
target={[0, 30, 0]}
|
||||
enablePan={false}
|
||||
minDistance={120}
|
||||
maxDistance={700}
|
||||
maxPolarAngle={Math.PI / 2 - 0.05}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Group } from 'three';
|
||||
import { GroupProps } from '@react-three/fiber';
|
||||
import { useGLTF } from '@react-three/drei';
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
export function StageLightModel(props: GroupProps) {
|
||||
// @ts-expect-error properties `nodes` and `materials` actually exist
|
||||
const { materials, nodes } = useGLTF('/models/stage-light.gltf');
|
||||
const ref = useRef<Group>(null!);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current.lookAt(32 * (Math.random() - 0.5), 32 * (Math.random() - 0.5), 32 * (Math.random() - 0.5));
|
||||
ref.current.rotateY(-Math.PI / 2);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group ref={ref} {...props} dispose={null}>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={nodes.Cube001.geometry}
|
||||
material={materials.Base}
|
||||
position={[-2, 0, 0]}
|
||||
receiveShadow
|
||||
/>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={nodes.Cube001_1.geometry}
|
||||
material={materials.Light}
|
||||
position={[-2, 0, 0]}
|
||||
receiveShadow
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload('/models/stage-light.gltf');
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useGLTF } from '@react-three/drei';
|
||||
import { Suspense, useLayoutEffect, useRef } from 'react';
|
||||
import type { Group, Mesh, MeshStandardMaterial } from 'three';
|
||||
|
||||
useGLTF.preload('/models/stage-light.gltf');
|
||||
|
||||
type StageLightGLTF = {
|
||||
nodes: {
|
||||
Cube001: Mesh;
|
||||
Cube001_1: Mesh;
|
||||
};
|
||||
materials: {
|
||||
Base: MeshStandardMaterial;
|
||||
Light: MeshStandardMaterial;
|
||||
};
|
||||
};
|
||||
|
||||
// GLTF authored in old project's ~1unit=16mm scale; bring it into our mm world.
|
||||
const FIXTURE_SCALE = 16;
|
||||
// Random aim point lives in a box around the pile so the lids cast a different gobo on the floor
|
||||
// per fixture.
|
||||
const AIM_RANGE = 150;
|
||||
|
||||
function StageLightModel({ position }: { position: [number, number, number] }) {
|
||||
const { materials, nodes } = useGLTF('/models/stage-light.gltf') as unknown as StageLightGLTF;
|
||||
const ref = useRef<Group>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) return;
|
||||
ref.current.lookAt(
|
||||
AIM_RANGE * (Math.random() - 0.5),
|
||||
AIM_RANGE * (Math.random() - 0.5),
|
||||
AIM_RANGE * (Math.random() - 0.5),
|
||||
);
|
||||
ref.current.rotateY(-Math.PI / 2);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group ref={ref} position={position} scale={FIXTURE_SCALE} dispose={null}>
|
||||
<mesh
|
||||
castShadow
|
||||
receiveShadow
|
||||
geometry={nodes.Cube001.geometry}
|
||||
material={materials.Base}
|
||||
position={[-2, 0, 0]}
|
||||
/>
|
||||
<mesh
|
||||
castShadow
|
||||
receiveShadow
|
||||
geometry={nodes.Cube001_1.geometry}
|
||||
material={materials.Light}
|
||||
position={[-2, 0, 0]}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
position: [number, number, number];
|
||||
intensity?: number;
|
||||
};
|
||||
|
||||
export function StageLight({ position, intensity = 150000 }: Props) {
|
||||
const [x, y, z] = position;
|
||||
|
||||
// Vertical pole, offset slightly "outward" from the fixture so it doesn't clip through the model.
|
||||
// Runs from the light down to the floor.
|
||||
const poleOffset = 30;
|
||||
const poleX = x + Math.sign(x || 1) * poleOffset;
|
||||
const poleZ = z + Math.sign(z || 1) * poleOffset;
|
||||
const poleBottomY = -5;
|
||||
const poleHeight = y - poleBottomY;
|
||||
const poleCenterY = (y + poleBottomY) / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<spotLight
|
||||
position={position}
|
||||
castShadow
|
||||
penumbra={1}
|
||||
intensity={intensity}
|
||||
angle={Math.PI / 3}
|
||||
decay={2}
|
||||
shadow-mapSize={[2048, 2048]}
|
||||
shadow-bias={-0.0005}
|
||||
shadow-normalBias={0.05}
|
||||
shadow-camera-near={5}
|
||||
shadow-camera-far={1500}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<StageLightModel position={position} />
|
||||
</Suspense>
|
||||
<mesh position={[poleX, poleCenterY, poleZ]}>
|
||||
<cylinderGeometry args={[5, 5, poleHeight, 6]} />
|
||||
<meshBasicMaterial color="#0a0a0a" />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: -25% 0 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { InfoBox } from './info-box';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import { Row } from './row';
|
||||
import { useEffect } from 'react';
|
||||
import { useNumistaTypes } from '@/core/hooks/use-types';
|
||||
import { useShowcaseStore } from './store';
|
||||
import styles from './index.module.css';
|
||||
|
||||
export const Showcase = () => {
|
||||
const setTypes = useShowcaseStore(state => state.setTypes);
|
||||
const isSpinning = useShowcaseStore(state => state.isSpinning);
|
||||
const nextIndex = useShowcaseStore(state => state.nextIndex);
|
||||
const previousIndex = useShowcaseStore(state => state.previousIndex);
|
||||
const toggleSpin = useShowcaseStore(state => state.toggleSpin);
|
||||
const setOrbitControlsRef = useShowcaseStore(state => state.setOrbitControlsRef);
|
||||
const hasTextureBase = useShowcaseStore(state => state.textureBase);
|
||||
const hasTextureBump = useShowcaseStore(state => state.textureBump);
|
||||
|
||||
useNumistaTypes({ onSuccess: setTypes });
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = ({ key }: KeyboardEvent) => {
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
return previousIndex();
|
||||
|
||||
case 'ArrowRight':
|
||||
return nextIndex();
|
||||
|
||||
case ' ':
|
||||
return toggleSpin();
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [nextIndex, previousIndex, toggleSpin]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Canvas className={styles.canvas}>
|
||||
<ambientLight />
|
||||
<directionalLight position={[0, 0.125, 1]} />
|
||||
<Row key={`${hasTextureBase}-${hasTextureBump}`} />
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
enableRotate={!isSpinning}
|
||||
maxDistance={10}
|
||||
minDistance={2}
|
||||
ref={setOrbitControlsRef}
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
<InfoBox />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Button } from 'primereact/button';
|
||||
import { getFlagEmoji } from '@/core/utils/flags';
|
||||
import { useShowcaseStore } from './store';
|
||||
|
||||
export const InfoBox = () => {
|
||||
const isSpinning = useShowcaseStore(state => state.isSpinning);
|
||||
const getType = useShowcaseStore(state => state.getType);
|
||||
const toggleSpin = useShowcaseStore(state => state.toggleSpin);
|
||||
const currentIndex = useShowcaseStore(state => state.currentIndex);
|
||||
const nextIndex = useShowcaseStore(state => state.nextIndex);
|
||||
const previousIndex = useShowcaseStore(state => state.previousIndex);
|
||||
const total = useShowcaseStore(state => state.types.length);
|
||||
|
||||
const currentType = getType();
|
||||
|
||||
if (!currentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { category, count = 1, issuer, max_year, min_year, size, thickness, title, weight } = currentType;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
bottom: 32,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
maxWidth: '100vw',
|
||||
padding: 16,
|
||||
width: 720
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{`${getFlagEmoji(issuer?.iso)} `}
|
||||
<small>{issuer?.name}</small>
|
||||
</span>
|
||||
<h1 style={{ textAlign: 'center' }}>{title}</h1>
|
||||
<p>{min_year !== max_year ? `${min_year} - ${max_year}` : min_year}</p>
|
||||
<small>{`${count ?? 1} in collection`}</small>
|
||||
{category === 'coin' && (
|
||||
<>
|
||||
{weight && <p>{`${weight}g`}</p>}
|
||||
{size && <p>{`${size}mm`}</p>}
|
||||
{thickness && <p>{`${thickness}mm`}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon={`pi pi-${isSpinning ? 'pause' : 'play'}`}
|
||||
onClick={toggleSpin}
|
||||
size={'small'}
|
||||
style={{ marginBottom: '8px' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={currentIndex <= 0}
|
||||
icon={'pi pi-arrow-left'}
|
||||
onClick={previousIndex}
|
||||
severity={'secondary'}
|
||||
size={'small'}
|
||||
/>
|
||||
|
||||
<span>{`${currentIndex + 1}/${total}`}</span>
|
||||
|
||||
<Button
|
||||
disabled={currentIndex >= total - 1}
|
||||
icon={'pi pi-arrow-right'}
|
||||
onClick={nextIndex}
|
||||
severity={'secondary'}
|
||||
size={'small'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
export function InfoPanel({ piece }: { piece: PieceWithIssuer }) {
|
||||
const subtitle = [
|
||||
piece.currency_numeric_value !== null && piece.currency_name
|
||||
? `${piece.currency_numeric_value} ${piece.currency_name}`
|
||||
: null,
|
||||
piece.year_min
|
||||
? piece.year_max && piece.year_max !== piece.year_min
|
||||
? `${piece.year_min}–${piece.year_max}`
|
||||
: `${piece.year_min}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
const data = (
|
||||
piece.category === 'banknote'
|
||||
? [piece.width ? `↔ ${piece.width} mm` : null, piece.height ? `↕ ${piece.height} mm` : null]
|
||||
: [
|
||||
piece.diameter ? `⌀ ${piece.diameter} mm` : null,
|
||||
piece.thickness ? `↕ ${piece.thickness} mm` : null,
|
||||
piece.weight ? `${piece.weight} g` : null,
|
||||
]
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div className="absolute top-16 sm:top-6 left-6 pointer-events-none select-none max-w-sm">
|
||||
<div className="flex items-center gap-2.5 mb-3">
|
||||
{piece.issuer_flag_path && (
|
||||
<img
|
||||
src={`/flags/${piece.issuer_flag_path}`}
|
||||
alt=""
|
||||
className="w-6 h-4 object-contain opacity-90"
|
||||
/>
|
||||
)}
|
||||
<span className="caps text-mini text-ink-dim">{piece.issuer_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-3 whitespace-nowrap">
|
||||
<h1 className="text-4xl text-ink leading-[1.05]">{piece.title}</h1>
|
||||
{piece.count > 1 && <span className="caps text-mini text-brass tnum">×{piece.count}</span>}
|
||||
</div>
|
||||
|
||||
{subtitle && <p className="text-data italic text-ink-dim mt-2">{subtitle}</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="w-12 h-px bg-rule-strong my-4" />
|
||||
<p className="text-data text-ink tnum whitespace-pre">{data}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`https://en.numista.com/catalogue/pieces${piece.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="caps text-mini text-brass hover:text-ink mt-5 inline-block pointer-events-auto transition-colors"
|
||||
>
|
||||
view on numista ↗
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
import type { Group, RectAreaLight } from 'three';
|
||||
|
||||
export function LightRig({ children }: { children: ReactNode }) {
|
||||
const { camera } = useThree();
|
||||
const ref = useRef<Group>(null);
|
||||
useFrame(() => {
|
||||
if (ref.current) ref.current.quaternion.copy(camera.quaternion);
|
||||
});
|
||||
return <group ref={ref}>{children}</group>;
|
||||
}
|
||||
|
||||
export function StudioLight({
|
||||
position,
|
||||
color = '#ffffff',
|
||||
intensity,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
color?: string;
|
||||
intensity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const ref = useRef<RectAreaLight>(null);
|
||||
useFrame(() => {
|
||||
ref.current?.lookAt(0, 0, 0);
|
||||
});
|
||||
return (
|
||||
<rectAreaLight
|
||||
ref={ref}
|
||||
position={position}
|
||||
color={color}
|
||||
intensity={intensity}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
export function NavButton({
|
||||
onPress,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
aria-label={label}
|
||||
className="w-12 h-12 border border-rule bg-card/80 backdrop-blur-sm flex items-center justify-center text-ink text-xl outline-none transition-colors data-[hovered]:border-brass data-[hovered]:text-brass data-[pressed]:bg-card data-[focus-visible]:border-brass"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { type RefObject, Suspense, useRef, useState } from 'react';
|
||||
import type { Group } from 'three';
|
||||
import { Piece } from '@/components/piece';
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
const SLIDE_DISTANCE = 0.1;
|
||||
const TRANSITION_MS = 400;
|
||||
const SPIN_SPEED = 1.5;
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
|
||||
function spinAxis(piece: PieceWithIssuer): 'x' | 'y' {
|
||||
return piece.orientation === 'coin' ? 'x' : 'y';
|
||||
}
|
||||
|
||||
// Three permanent slots: current, +1 (next), -1 (prev). All mount the next/prev atlases up front so
|
||||
// navigation has zero load latency.
|
||||
//
|
||||
// On nav, the "behind" slot teleports off-screen to the opposite side with a fresh look-ahead piece
|
||||
// — invisible to the user because it's outside the camera frustum the whole time. The other two
|
||||
// slots animate into / out of view.
|
||||
export function PieceStage({
|
||||
pieces,
|
||||
index,
|
||||
spin,
|
||||
transitioningRef,
|
||||
}: {
|
||||
pieces: PieceWithIssuer[];
|
||||
index: number;
|
||||
spin: boolean;
|
||||
transitioningRef: RefObject<boolean>;
|
||||
}) {
|
||||
const N = pieces.length;
|
||||
|
||||
const slot0Ref = useRef<Group>(null);
|
||||
const slot1Ref = useRef<Group>(null);
|
||||
const slot2Ref = useRef<Group>(null);
|
||||
const slotRefs = [slot0Ref, slot1Ref, slot2Ref] as const;
|
||||
|
||||
const [slotPieces, setSlotPieces] = useState<(PieceWithIssuer | null)[]>(() => [
|
||||
pieces[index] ?? null,
|
||||
pieces[(index + 1) % N] ?? null,
|
||||
pieces[(index - 1 + N) % N] ?? null,
|
||||
]);
|
||||
|
||||
// Which slot index (0..2) currently sits at center. The other two are at ±D — (center+1)%3 is the
|
||||
// +D ("next") slot, (center+2)%3 is the −D ("prev") slot.
|
||||
const centerSlotRef = useRef(0);
|
||||
const slotSpinsRef = useRef([0, 0, 0]);
|
||||
const handledIdxRef = useRef(index);
|
||||
const txRef = useRef<{ direction: 1 | -1; startedAt: number } | null>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// 1. Detect a nav (only if no transition is already in flight).
|
||||
if (index !== handledIdxRef.current && !txRef.current) {
|
||||
const c = centerSlotRef.current;
|
||||
const nextSlot = (c + 1) % 3;
|
||||
const prevSlot = (c + 2) % 3;
|
||||
const targetId = pieces[index]?.id;
|
||||
|
||||
// Direction is whichever side currently holds the target piece.
|
||||
let d: 1 | -1 | 0 = 0;
|
||||
if (slotPieces[nextSlot]?.id === targetId) d = 1;
|
||||
else if (slotPieces[prevSlot]?.id === targetId) d = -1;
|
||||
|
||||
if (d === 0) {
|
||||
// Target isn't in the prev/next window (URL jump, browser back-back-back, etc.). Snap
|
||||
// without animation.
|
||||
setSlotPieces([
|
||||
pieces[index] ?? null,
|
||||
pieces[(index + 1) % N] ?? null,
|
||||
pieces[(index - 1 + N) % N] ?? null,
|
||||
]);
|
||||
slotSpinsRef.current = [0, 0, 0];
|
||||
centerSlotRef.current = 0;
|
||||
handledIdxRef.current = index;
|
||||
} else {
|
||||
const teleportSlot = d === 1 ? prevSlot : nextSlot;
|
||||
const lookAheadIdx = (index + d + N) % N;
|
||||
|
||||
setSlotPieces((cur) => {
|
||||
const next = [...cur];
|
||||
next[teleportSlot] = pieces[lookAheadIdx] ?? null;
|
||||
return next;
|
||||
});
|
||||
const teleportRef = slotRefs[teleportSlot].current;
|
||||
if (teleportRef) teleportRef.position.x = d * SLIDE_DISTANCE;
|
||||
slotSpinsRef.current[teleportSlot] = 0;
|
||||
|
||||
txRef.current = { direction: d, startedAt: performance.now() };
|
||||
transitioningRef.current = true;
|
||||
handledIdxRef.current = index;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Spin per-slot (independent accumulators so each piece keeps its phase).
|
||||
if (spin) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (slotPieces[i]) slotSpinsRef.current[i] += delta * SPIN_SPEED;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const slot = slotRefs[i].current;
|
||||
const piece = slotPieces[i];
|
||||
if (!slot || !piece) continue;
|
||||
slot.rotation.set(0, 0, 0);
|
||||
slot.rotation[spinAxis(piece)] = slotSpinsRef.current[i];
|
||||
}
|
||||
|
||||
// 3. Positions: either static (no tx) or animating.
|
||||
const tx = txRef.current;
|
||||
if (!tx) {
|
||||
const c = centerSlotRef.current;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const slot = slotRefs[i].current;
|
||||
if (!slot) continue;
|
||||
if (i === c) slot.position.x = 0;
|
||||
else if (i === (c + 1) % 3) slot.position.x = SLIDE_DISTANCE;
|
||||
else slot.position.x = -SLIDE_DISTANCE;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - tx.startedAt;
|
||||
const t = Math.min(1, elapsed / TRANSITION_MS);
|
||||
const eased = easeOutCubic(t);
|
||||
|
||||
const c = centerSlotRef.current;
|
||||
const slidingOut = c;
|
||||
const slidingIn = tx.direction === 1 ? (c + 1) % 3 : (c + 2) % 3;
|
||||
// The third slot is the teleported one — already pinned at d*SLIDE_DISTANCE.
|
||||
|
||||
if (slotRefs[slidingOut].current) {
|
||||
slotRefs[slidingOut].current.position.x = -tx.direction * SLIDE_DISTANCE * eased;
|
||||
}
|
||||
if (slotRefs[slidingIn].current) {
|
||||
slotRefs[slidingIn].current.position.x = tx.direction * SLIDE_DISTANCE * (1 - eased);
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
txRef.current = null;
|
||||
transitioningRef.current = false;
|
||||
centerSlotRef.current = slidingIn;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{[0, 1, 2].map((i) => {
|
||||
const slotPiece = slotPieces[i];
|
||||
return (
|
||||
<group key={i} ref={slotRefs[i]}>
|
||||
<group rotation={[Math.PI / 2, 0, 0]}>
|
||||
<Suspense fallback={null}>
|
||||
{slotPiece && <Piece piece={slotPiece} atlas={1024} />}
|
||||
</Suspense>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { BanknoteInstance } from '@/components/banknote';
|
||||
import { CoinInstance } from '@/components/coin';
|
||||
import { Mesh } from 'three';
|
||||
import { MeshProps, useFrame } from '@react-three/fiber';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { usePrevious } from '@/core/hooks/use-previous';
|
||||
import { useShowcaseStore } from './store';
|
||||
|
||||
const SpinninType = ({
|
||||
isSelected,
|
||||
typeProps,
|
||||
...props
|
||||
}: MeshProps & {
|
||||
isSelected?: boolean;
|
||||
typeProps?: NumistaType;
|
||||
}) => {
|
||||
const ref = useRef<Mesh>(null!);
|
||||
const isSpinning = useShowcaseStore(state => state.isSpinning);
|
||||
const isTransitioning = useShowcaseStore(state => state.isTransitioning);
|
||||
const Instance = typeProps?.category === 'banknote' ? BanknoteInstance : CoinInstance;
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
if (!isSelected || !isSpinning || !typeProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeProps.category === 'banknote') {
|
||||
ref.current.rotation.x += delta;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (typeProps.orientation) {
|
||||
case 'medal':
|
||||
ref.current.rotation.x += delta;
|
||||
break;
|
||||
|
||||
default:
|
||||
ref.current.rotation.z += delta;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (!typeProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = typeProps.size ?? 16;
|
||||
const thickness = typeProps.thickness ?? 2;
|
||||
|
||||
return (
|
||||
<mesh {...props}>
|
||||
<mesh
|
||||
ref={ref}
|
||||
{...(typeProps.category !== 'banknote'
|
||||
? {
|
||||
scale: [size / 16, thickness / 16, size / 16]
|
||||
}
|
||||
: { rotation: [Math.PI / 2, Math.PI, Math.PI / 2] })}
|
||||
{...(!isSelected &&
|
||||
!isTransitioning && {
|
||||
scale: [0, 0, 0]
|
||||
})}
|
||||
>
|
||||
<Instance {...typeProps} />
|
||||
</mesh>
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
export const Row = () => {
|
||||
const ref = useRef<Mesh>(null!);
|
||||
const currentIndex = useShowcaseStore(state => state.currentIndex);
|
||||
const getType = useShowcaseStore(state => state.getType);
|
||||
const isTransitioning = useShowcaseStore(state => state.isTransitioning);
|
||||
const endTransition = useShowcaseStore(state => state.endTransition);
|
||||
const beginTransition = useShowcaseStore(state => state.beginTransition);
|
||||
const types = useShowcaseStore(state => state.types);
|
||||
const [spacing, setSpacing] = useState<number>(0);
|
||||
const delayedIndex = usePrevious(currentIndex);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSpacing = () => {
|
||||
setSpacing(Math.sqrt((48 * window.innerWidth) / window.innerHeight));
|
||||
beginTransition();
|
||||
};
|
||||
|
||||
updateSpacing();
|
||||
|
||||
window.addEventListener('resize', updateSpacing);
|
||||
|
||||
return () => window.removeEventListener('resize', updateSpacing);
|
||||
}, [beginTransition]);
|
||||
|
||||
useFrame(() => {
|
||||
if (!isTransitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = -currentIndex * spacing;
|
||||
const diff = ref.current.position.x - target;
|
||||
|
||||
if (Math.abs(diff) > 1 / 32) {
|
||||
ref.current.position.x -= diff / 16;
|
||||
} else {
|
||||
endTransition();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
{types?.map(
|
||||
(type, index) =>
|
||||
Math.abs(index - delayedIndex) <= 2 && (
|
||||
<Suspense fallback={null} key={type.id}>
|
||||
<SpinninType
|
||||
isSelected={currentIndex === index}
|
||||
position={[index * spacing, 0, 0]}
|
||||
rotation={[Math.PI / 2, Math.PI / 2, 0]}
|
||||
typeProps={getType(index)}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { MutableRefObject, createRef } from 'react';
|
||||
import { NumistaType } from '@/types/numista';
|
||||
import { OrbitControls } from 'three-stdlib';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ShowcaseStore {
|
||||
beginTransition: () => void;
|
||||
currentIndex: number;
|
||||
endTransition: () => void;
|
||||
getType: (index?: number) => NumistaType | undefined;
|
||||
isSpinning: boolean;
|
||||
isTransitioning: boolean;
|
||||
nextIndex: () => void;
|
||||
orbitControlsRef: MutableRefObject<OrbitControls | null>;
|
||||
previousIndex: () => void;
|
||||
setOrbitControlsRef: (ref: OrbitControls | null) => void;
|
||||
setTextureBase: (value: boolean) => void;
|
||||
setTextureBump: (value: boolean) => void;
|
||||
setTypes: (types?: NumistaType[] | null) => void;
|
||||
textureBase: boolean;
|
||||
textureBump: boolean;
|
||||
toggleSpin: () => void;
|
||||
types: NumistaType[];
|
||||
}
|
||||
|
||||
export const useShowcaseStore = create<ShowcaseStore>((set, get) => ({
|
||||
beginTransition: () => set(() => ({ isTransitioning: true })),
|
||||
currentIndex: 0,
|
||||
endTransition: () => set(() => ({ isTransitioning: false })),
|
||||
getType: index => {
|
||||
const { currentIndex, types } = get();
|
||||
|
||||
return types[index ?? currentIndex];
|
||||
},
|
||||
isSpinning: true,
|
||||
isTransitioning: false,
|
||||
nextIndex: () =>
|
||||
set(state => {
|
||||
state.orbitControlsRef.current?.setAzimuthalAngle(0);
|
||||
state.orbitControlsRef.current?.setPolarAngle(Math.PI / 2);
|
||||
const currentIndex = Math.min(state.currentIndex + 1, state.types.length - 1);
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
currentType: state.types[currentIndex],
|
||||
isTransitioning: true
|
||||
};
|
||||
}),
|
||||
orbitControlsRef: createRef(),
|
||||
previousIndex: () =>
|
||||
set(state => {
|
||||
state.orbitControlsRef.current?.setAzimuthalAngle(0);
|
||||
state.orbitControlsRef.current?.setPolarAngle(Math.PI / 2);
|
||||
const currentIndex = Math.max(state.currentIndex - 1, 0);
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
currentType: state.types[currentIndex],
|
||||
isTransitioning: true
|
||||
};
|
||||
}),
|
||||
setOrbitControlsRef: instance =>
|
||||
set(() => ({
|
||||
orbitControlsRef: { current: instance }
|
||||
})),
|
||||
setTextureBase: value =>
|
||||
set(() => ({
|
||||
textureBase: value
|
||||
})),
|
||||
setTextureBump: value =>
|
||||
set(() => ({
|
||||
textureBump: value
|
||||
})),
|
||||
setTypes: types =>
|
||||
set(() => ({
|
||||
currentIndex: 0,
|
||||
isSpinning: true,
|
||||
isTransitioning: true,
|
||||
types: Array.isArray(types) ? types : []
|
||||
})),
|
||||
textureBase: true,
|
||||
textureBump: true,
|
||||
toggleSpin: () =>
|
||||
set(state => {
|
||||
if (!state.isSpinning) {
|
||||
state.orbitControlsRef.current?.setAzimuthalAngle(0);
|
||||
state.orbitControlsRef.current?.setPolarAngle(Math.PI / 2);
|
||||
|
||||
return { isSpinning: true };
|
||||
}
|
||||
|
||||
return { isSpinning: false };
|
||||
}),
|
||||
types: []
|
||||
}));
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const breakpoints = {
|
||||
md: 992,
|
||||
ms: 768,
|
||||
sm: 576
|
||||
} as const;
|
||||
|
||||
type Breakpoint = keyof typeof breakpoints;
|
||||
|
||||
export function useMediaQuery(which: 'max' | 'min', breakpoint: Breakpoint): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(`(${which}-width: ${breakpoints[breakpoint]}px)`);
|
||||
const listener = () => setMatches(media.matches);
|
||||
listener();
|
||||
|
||||
media.addEventListener('change', listener);
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', listener);
|
||||
};
|
||||
}, [breakpoint, which]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function usePrevious<T>(value: T): T {
|
||||
const ref = useRef<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user