Reimplemented (but better)
Build and Deploy / build-deploy (push) Successful in 28s

This commit is contained in:
2026-06-12 16:04:35 +01:00
parent b98df2f17c
commit 37ff3dc924
147 changed files with 5379 additions and 16886 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.env.*
!.env.example
.git
node_modules
dist
data
+9
View File
@@ -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=
-3
View File
@@ -1,3 +0,0 @@
node_modules
public
.next
-17
View File
@@ -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'
}
};
+30 -5
View File
@@ -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
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
node_modules
public/models
+10 -18
View File
@@ -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"]
+22 -30
View File
@@ -1,38 +1,30 @@
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).
# Coins
## Getting Started
A personal coin collection, rendered in 3D from Numista data.
First, run the development server:
Three pages:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
- `/` — country grid over a hail of falling coins.
- `/showcase` — one coin at a time on a lit turntable.
- `/pile` — every coin in the filter dropped onto a floor under physics.
## Stack
Vite · React 19 · react-three/fiber + drei + rapier · Tailwind v4 · Bun + `bun:sqlite`.
## Ingest
```sh
bun run ingest add <numista-id> [+count]
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Fetches the type from Numista, downloads images, bakes atlases, upserts the row. `remove`, `set-count`, `refetch`, `rebake` also available.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
## Dev
[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`.
```sh
bun install
bun run dev
```
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.
Data (catalogue + atlases + flags) lives in `./data/`.
-21
View File
@@ -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);
}
});
});
};
-12
View File
@@ -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);
-3
View File
@@ -1,3 +0,0 @@
import { Database } from 'bun:sqlite';
export const db = new Database('./prisma/main.db');
-85
View File
@@ -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;
}
};
-50
View File
@@ -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}"
)
)
-34
View File
@@ -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
-51
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
}
}
-4
View File
@@ -1,4 +0,0 @@
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="esnext" />
/// <reference types="bun-types" />
+361 -1094
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -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
View File
@@ -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>
-14
View File
@@ -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
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.
-4370
View File
File diff suppressed because one or more lines are too long
-199
View File
@@ -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
View File
@@ -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');
-12
View File
@@ -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 }
}
})
});
};
-64
View File
@@ -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;
};
-19
View File
@@ -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);
};
-3
View File
@@ -1,3 +0,0 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
-43
View File
@@ -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
}
});
};
-14
View File
@@ -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;
};
-157
View File
@@ -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[];
};
-13
View File
@@ -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
}
}
};
+6
View File
@@ -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;
-102
View File
@@ -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'} />
)}
</>
);
};
-128
View File
@@ -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} />
</>
);
};
-52
View File
@@ -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} />
</>
);
}
-52
View File
@@ -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} />;
};
+28
View File
@@ -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>
);
}
+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

-359
View File
@@ -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>
);
};
+50
View File
@@ -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>
);
}
+23
View File
@@ -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}
/>
</>
);
};
+82
View File
@@ -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>
);
}
-32
View File
@@ -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>
</>
);
};
+8
View File
@@ -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>
);
}
+217
View File
@@ -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>
</>
);
}
+64
View File
@@ -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>
);
}
-48
View File
@@ -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>
);
};
-8
View File
@@ -1,8 +0,0 @@
.wrapper {
position: absolute;
inset: 0;
}
.canvas {
height: 100%;
}
+41 -44
View File
@@ -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>
);
};
}
+76
View File
@@ -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>
);
}
+15
View File
@@ -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];
}
-65
View File
@@ -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>
);
};
+7
View File
@@ -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>
);
}
+163
View File
@@ -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;
}
+72
View File
@@ -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 />
);
}
+229
View File
@@ -0,0 +1,229 @@
import * as THREE from 'three';
import { ATLAS_LAYOUT, type AtlasSize } from '@/atlas-layout';
export type ExtrudeParams = {
// Outer outline in the XZ 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 XZ 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;
}
+60
View File
@@ -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;
}
+75
View File
@@ -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} />
);
}
+296
View File
@@ -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.030.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; ~610 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;
}
+118
View File
@@ -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) };
}
}
}
+19
View File
@@ -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} />;
}
-46
View File
@@ -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>
);
};
+145
View File
@@ -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} />
);
}
+64
View File
@@ -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;
}
+13
View File
@@ -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>
);
}
-3
View File
@@ -1,3 +0,0 @@
.canvas {
height: 100%;
}
-99
View File
@@ -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>
</>
);
};
+63
View File
@@ -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}
/>
</>
);
}
-36
View File
@@ -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');
+99
View File
@@ -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>
</>
);
}
-8
View File
@@ -1,8 +0,0 @@
.wrapper {
position: absolute;
inset: -25% 0 0;
}
.canvas {
height: 100%;
}
-62
View File
@@ -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>
);
};
-96
View File
@@ -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>
);
};
+66
View File
@@ -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>
);
}
+41
View File
@@ -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}
/>
);
}
+22
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
})}
</>
);
}
-128
View File
@@ -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>
);
};
-95
View File
@@ -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: []
}));
-27
View File
@@ -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;
}
-11
View File
@@ -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;
}
-30
View File
@@ -1,30 +0,0 @@
import { NumistaType } from '@/types/numista';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
export const useNumistaTypes = (options?: {
filterBanknotes?: boolean;
onSuccess?: (value?: NumistaType[] | null) => void;
}) => {
const router = useRouter();
const searchParams = router.asPath.split('?')[1];
const { data: coins } = useQuery<NumistaType[]>({
keepPreviousData: true,
queryFn: async () => {
const res = await fetch(`/api/types?${searchParams}` ?? '');
const types = await res.json();
options?.onSuccess?.(types);
return types;
},
queryKey: ['types', searchParams],
refetchOnReconnect: false,
refetchOnWindowFocus: false
});
if (options?.filterBanknotes) {
return coins?.filter(({ category }) => category !== 'banknote');
}
return coins;
};

Some files were not shown because too many files have changed in this diff Show More