This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
"src/app.tsx",
|
||||
"src/main.tsx",
|
||||
"src/components/**/*",
|
||||
"src/clocks/crowd/**/*",
|
||||
"src/clocks/custom/**/*",
|
||||
"src/clocks/daylight/**/*"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Crowd Clock
|
||||
|
||||
> A clock that emerges from a small society
|
||||
|
||||
The clock is made of people. A few hundred of them stand on a tiled floor, and each one runs on the same handful of rules. Head toward wherever you're needed. Don't crowd the person next to you. Once in a while, as a treat, abandon your post and just wander off.
|
||||
|
||||
A clock falls out of that. People idle at each of the 12 hours, and the hands are nothing more than people walking together in step. Plenty of the crowd has no job at all and drifts around the open floor, because keeping the time isn't on any single person.
|
||||
|
||||
If someone leaves, the rest close the gap. Shuffle the roles and the work just redistributes. Nobody is essential and nobody is dismissible.
|
||||
|
||||
Click any person to meet them.
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
CLOCK_RADIUS_FRACTION,
|
||||
HAND_FALLOFF_FRACTION,
|
||||
HAND_INNER_FRACTION,
|
||||
HAND_LENGTHS,
|
||||
HAND_ROW_SPACING,
|
||||
HAND_ROWS,
|
||||
INDICATOR_FALLOFF_FRACTION,
|
||||
INDICATOR_INNER_FRACTION,
|
||||
INDICATOR_OUTER_FRACTION,
|
||||
} from './constants';
|
||||
|
||||
export interface Segment {
|
||||
ax: number;
|
||||
ay: number;
|
||||
bx: number;
|
||||
by: number;
|
||||
falloff: number; // canvas px; bigger reaches farther
|
||||
}
|
||||
|
||||
export function clockRadius(width: number, height: number): number {
|
||||
return Math.min(width, height) * CLOCK_RADIUS_FRACTION;
|
||||
}
|
||||
|
||||
// The 12 hour-indicator segments plus N parallel hand-row segments for the
|
||||
// given time, in absolute canvas px. `scale` adjusts fixed-pixel constants like
|
||||
// HAND_ROW_SPACING so the layout holds across screen sizes.
|
||||
export function buildAttractors(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
R: number,
|
||||
scale: number,
|
||||
now: Date,
|
||||
): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const indicatorFalloff = R * INDICATOR_FALLOFF_FRACTION;
|
||||
const handFalloff = R * HAND_FALLOFF_FRACTION;
|
||||
const rowSpacing = HAND_ROW_SPACING * scale;
|
||||
|
||||
for (let h = 0; h < 12; h++) {
|
||||
const theta = (h / 12) * Math.PI * 2 - Math.PI / 2;
|
||||
const cos = Math.cos(theta);
|
||||
const sin = Math.sin(theta);
|
||||
segments.push({
|
||||
ax: centerX + cos * R * INDICATOR_INNER_FRACTION,
|
||||
ay: centerY + sin * R * INDICATOR_INNER_FRACTION,
|
||||
bx: centerX + cos * R * INDICATOR_OUTER_FRACTION,
|
||||
by: centerY + sin * R * INDICATOR_OUTER_FRACTION,
|
||||
falloff: indicatorFalloff,
|
||||
});
|
||||
}
|
||||
|
||||
const seconds = now.getSeconds() + now.getMilliseconds() / 1000;
|
||||
const minutes = now.getMinutes() + seconds / 60;
|
||||
const hours = (now.getHours() % 12) + minutes / 60;
|
||||
const handAngles: Array<[number, number]> = [
|
||||
[(hours / 12) * Math.PI * 2 - Math.PI / 2, HAND_LENGTHS.hour],
|
||||
[(minutes / 60) * Math.PI * 2 - Math.PI / 2, HAND_LENGTHS.minute],
|
||||
];
|
||||
for (const [angle, lenFrac] of handAngles) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const ax = centerX + cos * R * HAND_INNER_FRACTION;
|
||||
const ay = centerY + sin * R * HAND_INNER_FRACTION;
|
||||
const bx = centerX + cos * R * lenFrac;
|
||||
const by = centerY + sin * R * lenFrac;
|
||||
const px = -sin;
|
||||
const py = cos;
|
||||
for (let k = 0; k < HAND_ROWS; k++) {
|
||||
const offset = (k - (HAND_ROWS - 1) / 2) * rowSpacing;
|
||||
segments.push({
|
||||
ax: ax + px * offset,
|
||||
ay: ay + py * offset,
|
||||
bx: bx + px * offset,
|
||||
by: by + py * offset,
|
||||
falloff: handFalloff,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Allocates a fresh object per call, ~3k times a frame at default settings.
|
||||
// V8 handles that fine; if it ever shows up in a profile, switch to scratch
|
||||
// out-params.
|
||||
export interface ClosestPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
d: number;
|
||||
}
|
||||
|
||||
export function closestPointOnSegment(px: number, py: number, s: Segment): ClosestPoint {
|
||||
const dx = s.bx - s.ax;
|
||||
const dy = s.by - s.ay;
|
||||
const len2 = dx * dx + dy * dy || 1;
|
||||
let t = ((px - s.ax) * dx + (py - s.ay) * dy) / len2;
|
||||
if (t < 0) t = 0;
|
||||
else if (t > 1) t = 1;
|
||||
const x = s.ax + t * dx;
|
||||
const y = s.ay + t * dy;
|
||||
return { x, y, d: Math.hypot(px - x, py - y) };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Tweakable knobs for the crowd simulation. Length/force/speed values are
|
||||
// given at the design viewport size (DESIGN_MIN_DIM) and multiplied by
|
||||
// getScale() at runtime so things look the same on any screen. Time-domain
|
||||
// constants (rates, frequencies, damping) are scale-invariant.
|
||||
|
||||
export const DESIGN_MIN_DIM = 927; // canvas px; the size everything was tuned at
|
||||
// Same on every viewport: the clock and people scale together, so the same
|
||||
// count always fits.
|
||||
export const DESIGN_COMPLYERS = 180;
|
||||
// Wanderer count comes from the empty area around the clock divided by R²
|
||||
// (person area scales with R² too). At the design viewport this gives 60.
|
||||
export const DESIGN_WANDERER_DENSITY = 8.5;
|
||||
|
||||
export const ROLE_SWITCH_RATE = 0.01; // base swap rate per wanderer per second
|
||||
// Wanderers near a viewport edge swap roles more often so they don't pile up
|
||||
// in the corners; the total count doesn't change, only where turnover happens.
|
||||
// The zone is per-axis: 0.5 means the outer half of the empty space between
|
||||
// clock and edge counts as the bias zone.
|
||||
export const EDGE_BIAS_ZONE_FRACTION = 0.5;
|
||||
export const EDGE_BIAS_STRENGTH = 4; // multiplier on swap rate at the edge
|
||||
|
||||
// Clock geometry, fractions of clock radius unless noted.
|
||||
export const CLOCK_RADIUS_FRACTION = 0.45; // of min(canvas width, height)
|
||||
export const INDICATOR_INNER_FRACTION = 0.9;
|
||||
export const INDICATOR_OUTER_FRACTION = 1.0;
|
||||
export const HAND_INNER_FRACTION = 0.13; // hands stop short of the center
|
||||
export const HAND_LENGTHS = {
|
||||
hour: 0.4,
|
||||
minute: 0.62,
|
||||
} as const;
|
||||
// Each hand is N parallel line attractors offset perpendicular to it. People
|
||||
// pack onto each row, making a thick hand without the hollow interior a single
|
||||
// wide strip would leave.
|
||||
export const HAND_ROWS = 2;
|
||||
export const HAND_ROW_SPACING = 10;
|
||||
|
||||
export const WALK_SPEED_MAX = 5.5;
|
||||
export const VELOCITY_DAMPING = 10; // 1/sec; over-damped to kill oscillation
|
||||
export const TEMPERATURE = 1.0; // jitter acceleration magnitude
|
||||
|
||||
// Attractor force, complyers only. Strongest at the attractor and decaying
|
||||
// with distance, so the nearest one wins once a complyer reaches it. Falloff
|
||||
// is per-attractor: indicators reach short, hands reach far so they win far-off
|
||||
// complyers. That pulls people out of indicator orbits without shaking loose
|
||||
// the ones already on the line.
|
||||
export const ATTRACTOR_STRENGTH = 1800;
|
||||
export const INDICATOR_FALLOFF_FRACTION = 0.09;
|
||||
export const HAND_FALLOFF_FRACTION = 0.15; // much longer reach
|
||||
|
||||
export const REPULSION_STRENGTH = 22000;
|
||||
export const REPULSION_RADIUS = 9;
|
||||
// Weak, longer-range second term: a soft "pressure" between distant pairs that
|
||||
// pushes people toward open space. Spreads complyers along attractor segments
|
||||
// and keeps wanderers from clumping. Applies to every pair regardless of role.
|
||||
export const REPULSION_FAR_STRENGTH = 1500;
|
||||
export const REPULSION_FAR_RADIUS = 30;
|
||||
|
||||
// Hard positional separation for pairs that include a wanderer. Applied after
|
||||
// velocity integration, so it bypasses the speed cap and gives the visible
|
||||
// "shoving through the crowd" look.
|
||||
export const WANDERER_MIN_DISTANCE = 16;
|
||||
export const WANDERER_SEPARATION_RATE = 0.04; // fraction of overlap resolved per frame
|
||||
|
||||
// Wanderer behavior. Wanderers have no internal drive; they only react. Two
|
||||
// forces move them: the viewport bound that keeps them on screen, and a
|
||||
// long-range "crowd pressure" where every other person gives a soft-core
|
||||
// inverse-square push. The pressure never cuts off, so a wanderer always feels
|
||||
// which way is away from the crowd.
|
||||
export const WANDERER_BOUND_PADDING = 20; // canvas px from the viewport edge
|
||||
export const WANDERER_BOUND_STRENGTH = 12;
|
||||
export const WANDERER_PRESSURE_STRENGTH = 200;
|
||||
export const WANDERER_PRESSURE_SCALE = 30; // canvas px (the soft-core scale)
|
||||
|
||||
// Body proportions, multiples of head radius × size.
|
||||
export const PERSON_RADIUS = 3.5; // canvas px head radius at size=1
|
||||
export const PERSON_SIZE_VARIATION = 0.3; // ±15% around 1.0
|
||||
export const LIMB_RADIUS_FACTOR = 0.45;
|
||||
export const SHOULDER_OFFSET_FACTOR = 0.75;
|
||||
export const ARM_SWING_FACTOR = 0.9; // max forward/back arm offset at full speed
|
||||
export const STROKE_COLOR = '#000';
|
||||
export const STROKE_FRACTION = 0.07;
|
||||
export const STROKE_MIN_PX = 0.7;
|
||||
|
||||
// Hairline-curve tuning per shape. The curve meets the head perimeter at
|
||||
// `halfAngle` from facing; `apex` is the quadratic control-point x as a
|
||||
// multiple of r. `parted` has two humps meeting at the part, inset `partInset`
|
||||
// behind the chord. `balding` is a horseshoe around the back and sides with an
|
||||
// inner ellipse cutting out the bald crown; `innerStretch` elongates that
|
||||
// ellipse front-to-back and `taper` sets how smoothly the front edge joins it.
|
||||
export const HAIRLINE_PARAMS = {
|
||||
convex: { halfAngle: 1.15, apex: 0.88 },
|
||||
concave: { halfAngle: 1.05, apex: 0.3 },
|
||||
parted: { halfAngle: 1.0, apex: 0.72, partInset: 0.05 },
|
||||
balding: {
|
||||
outerHalfAngle: 1.0,
|
||||
innerHalfAngle: 1.45,
|
||||
innerR: 0.55,
|
||||
innerStretch: 1.15,
|
||||
taper: 0.4,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const BASE_GAIT_FREQUENCY = 1.0; // cycles/sec at WALK_SPEED_MAX
|
||||
export const FACING_SMOOTHING = 12; // higher = snappier turn to face velocity
|
||||
export const FACING_SPEED_THRESHOLD = 0.8; // below this, facing is locked
|
||||
export const ANIM_VELOCITY_SMOOTHING = 4; // 1/sec low-pass on velocity for anim
|
||||
export const ANIM_MIN_SPEED = 1.2; // below this smoothed speed, amp = 0
|
||||
|
||||
export const DNA_SIZE_BUCKETS = 16;
|
||||
export const DNA_MIN_SIZE = 1 - PERSON_SIZE_VARIATION / 2;
|
||||
export const DNA_MAX_SIZE = 1 + PERSON_SIZE_VARIATION / 2;
|
||||
@@ -0,0 +1,11 @@
|
||||
.canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
}
|
||||
|
||||
.canvas[data-hover="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAnimationLoop } from '@/hooks/use-animation-loop';
|
||||
import { useCanvas } from '@/hooks/use-canvas';
|
||||
import { clockRadius } from './attractors';
|
||||
import { BASE_GAIT_FREQUENCY, DNA_MAX_SIZE, PERSON_RADIUS, WALK_SPEED_MAX } from './constants';
|
||||
import styles from './crowd-clock.module.css';
|
||||
import { CustomizePanel } from './customize-panel';
|
||||
import { type Dna, dnaFromPerson, dnaToAttributes } from './dna';
|
||||
import { Floor } from './floor';
|
||||
import { useDnaHash } from './hooks/use-dna-hash';
|
||||
import { drawPerson } from './render-person';
|
||||
import { getPopulation, getScale } from './scaling';
|
||||
import { createPerson, type Person, stepSimulation } from './simulation';
|
||||
|
||||
const SIM_FPS = 60;
|
||||
const SIM_DT_CLAMP = 1 / 20;
|
||||
|
||||
export function CrowdClock() {
|
||||
const [canvasRef, ctx] = useCanvas();
|
||||
const [dna, setDna] = useDnaHash();
|
||||
const [hoveringPerson, setHoveringPerson] = useState(false);
|
||||
|
||||
const peopleRef = useRef<Person[]>([]);
|
||||
const customizeGaitRef = useRef(0);
|
||||
|
||||
// People are seeded once on mount, never re-seeded on resize. The sim copes:
|
||||
// scale-dependent forces recompute each frame from the current viewport, and
|
||||
// the wanderer bound force herds any off-screen people back in. Re-seeding
|
||||
// would throw away the in-flight state every resize, which feels worse than a
|
||||
// one-time count mismatch.
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
if (peopleRef.current.length === 0) {
|
||||
const { complyers, wanderers } = getPopulation(ctx.width, ctx.height);
|
||||
const scale = getScale(ctx.width, ctx.height);
|
||||
const seed: Person[] = [];
|
||||
for (let i = 0; i < complyers; i++) {
|
||||
seed.push(createPerson(ctx.width, ctx.height, 'complyer', scale));
|
||||
}
|
||||
for (let i = 0; i < wanderers; i++) {
|
||||
seed.push(createPerson(ctx.width, ctx.height, 'wanderer', scale));
|
||||
}
|
||||
peopleRef.current = seed;
|
||||
}
|
||||
}, [ctx]);
|
||||
|
||||
const draw = useCallback(
|
||||
(_time: number, deltaMs: number) => {
|
||||
if (!ctx) return;
|
||||
if (dna) {
|
||||
renderCustomize(ctx.ctx, ctx.width, ctx.height, dna, deltaMs, customizeGaitRef);
|
||||
return;
|
||||
}
|
||||
const dt = Math.min(deltaMs / 1000, SIM_DT_CLAMP);
|
||||
const clockR = clockRadius(ctx.width, ctx.height);
|
||||
const scale = getScale(ctx.width, ctx.height);
|
||||
stepSimulation(peopleRef.current, dt, {
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
centerX: ctx.centerX,
|
||||
centerY: ctx.centerY,
|
||||
clockR,
|
||||
scale,
|
||||
});
|
||||
renderCrowd(ctx.ctx, ctx.width, ctx.height, peopleRef.current, scale);
|
||||
},
|
||||
[ctx, dna],
|
||||
);
|
||||
|
||||
useAnimationLoop(draw, SIM_FPS);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !ctx || dna) return;
|
||||
|
||||
function toCanvas(clientX: number, clientY: number) {
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left) * (ctx!.width / rect.width),
|
||||
y: (clientY - rect.top) * (ctx!.height / rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function findAt(x: number, y: number): Person | null {
|
||||
const scale = getScale(ctx!.width, ctx!.height);
|
||||
let closest: Person | null = null;
|
||||
let bestD2 = Infinity;
|
||||
for (const p of peopleRef.current) {
|
||||
const r = PERSON_RADIUS * scale * p.size;
|
||||
const dx = p.x - x;
|
||||
const dy = p.y - y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < r * r && d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
closest = p;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const { x, y } = toCanvas(e.clientX, e.clientY);
|
||||
setHoveringPerson(!!findAt(x, y));
|
||||
};
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const { x, y } = toCanvas(e.clientX, e.clientY);
|
||||
const p = findAt(x, y);
|
||||
if (p) setDna(dnaFromPerson(p));
|
||||
};
|
||||
canvas.addEventListener('mousemove', onMove);
|
||||
canvas.addEventListener('click', onClick);
|
||||
return () => {
|
||||
canvas.removeEventListener('mousemove', onMove);
|
||||
canvas.removeEventListener('click', onClick);
|
||||
};
|
||||
}, [canvasRef, ctx, dna, setDna]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Floor />
|
||||
<canvas ref={canvasRef} className={styles.canvas} data-hover={hoveringPerson} />
|
||||
{dna && <CustomizePanel dna={dna} onChange={setDna} onExit={() => setDna(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCrowd(
|
||||
c2d: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
people: Person[],
|
||||
scale: number,
|
||||
): void {
|
||||
c2d.clearRect(0, 0, width, height);
|
||||
for (const p of people) drawPerson(c2d, p, scale);
|
||||
}
|
||||
|
||||
function renderCustomize(
|
||||
c2d: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
dna: Dna,
|
||||
deltaMs: number,
|
||||
gaitRef: { current: number },
|
||||
): void {
|
||||
c2d.clearRect(0, 0, width, height);
|
||||
|
||||
gaitRef.current += BASE_GAIT_FREQUENCY * 2 * Math.PI * (deltaMs / 1000);
|
||||
const attributes = dnaToAttributes(dna);
|
||||
const baseRadius = Math.min(width * 0.18, height * 0.4);
|
||||
const displayScale = baseRadius / (PERSON_RADIUS * DNA_MAX_SIZE);
|
||||
|
||||
const sample: Person = {
|
||||
x: width * 0.75,
|
||||
y: height * 0.5,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
// Pin smoothed velocity at the cap so gait and arm-swing render at full
|
||||
// amplitude, giving the sample a walking-in-place look.
|
||||
svx: WALK_SPEED_MAX,
|
||||
svy: 0,
|
||||
role: 'complyer',
|
||||
facing: 0,
|
||||
gaitPhase: gaitRef.current,
|
||||
size: attributes.size * displayScale,
|
||||
skinColor: attributes.skinColor,
|
||||
hairColor: attributes.hairColor,
|
||||
hairShape: attributes.hairShape,
|
||||
};
|
||||
drawPerson(c2d, sample, 1);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
padding: 8vh 6vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
z-index: 10;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #2a2520;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 36px 1fr 36px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: none;
|
||||
border: 1px solid rgba(42, 37, 32, 0.3);
|
||||
color: inherit;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.step:hover {
|
||||
background: rgba(42, 37, 32, 0.1);
|
||||
}
|
||||
|
||||
.exit {
|
||||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.exit:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DNA_SIZE_BUCKETS } from './constants';
|
||||
import styles from './customize-panel.module.css';
|
||||
import { DNA_DOMAINS, type Dna } from './dna';
|
||||
import { HAIR_PALETTE, HAIR_SHAPES, hairName, SKIN_COLORS } from './palette';
|
||||
|
||||
interface CustomizePanelProps {
|
||||
dna: Dna;
|
||||
onChange: (next: Dna) => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const FIELDS: Array<{ key: keyof Dna; label: string }> = [
|
||||
{ key: 'skin', label: 'Skin' },
|
||||
{ key: 'hair', label: 'Hair' },
|
||||
{ key: 'shape', label: 'Hairline' },
|
||||
{ key: 'sizeBucket', label: 'Size' },
|
||||
];
|
||||
|
||||
export function CustomizePanel({ dna, onChange, onExit }: CustomizePanelProps) {
|
||||
const step = (key: keyof Dna, direction: number) => {
|
||||
const domain = DNA_DOMAINS[key];
|
||||
const next = (dna[key] + direction + domain) % domain;
|
||||
onChange({ ...dna, [key]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<h2 className={styles.heading}>Customize</h2>
|
||||
{FIELDS.map(({ key, label }) => (
|
||||
<div key={key} className={styles.row}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.step}
|
||||
onClick={() => step(key, -1)}
|
||||
aria-label={`Previous ${label.toLowerCase()}`}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className={styles.value}>{describeField(key, dna[key])}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.step}
|
||||
onClick={() => step(key, 1)}
|
||||
aria-label={`Next ${label.toLowerCase()}`}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className={styles.exit} onClick={onExit}>
|
||||
← Back to crowd
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeField(key: keyof Dna, value: number): string {
|
||||
switch (key) {
|
||||
case 'skin':
|
||||
return SKIN_COLORS[value];
|
||||
case 'hair':
|
||||
return hairName(HAIR_PALETTE[value].color);
|
||||
case 'shape':
|
||||
return HAIR_SHAPES[value];
|
||||
case 'sizeBucket':
|
||||
return `${value + 1}/${DNA_SIZE_BUCKETS}`;
|
||||
default:
|
||||
return assertNever(key);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(x: never): never {
|
||||
throw new Error(`Unhandled DNA field: ${String(x)}`);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// "DNA" packs every visible attribute of a Person (skin, hair color, hairline
|
||||
// shape, quantized size) into one integer, printed as a 3-char base36 string.
|
||||
// It isn't stable across palette changes: resize a palette and old hashes
|
||||
// decode to a different person.
|
||||
|
||||
import { DNA_MAX_SIZE, DNA_MIN_SIZE, DNA_SIZE_BUCKETS } from './constants';
|
||||
import { HAIR_PALETTE, HAIR_SHAPES, type HairShape, SKIN_COLORS } from './palette';
|
||||
|
||||
export interface Dna {
|
||||
skin: number;
|
||||
hair: number;
|
||||
shape: number;
|
||||
sizeBucket: number; // 0..DNA_SIZE_BUCKETS-1
|
||||
}
|
||||
|
||||
export const DNA_DOMAINS: Record<keyof Dna, number> = {
|
||||
skin: SKIN_COLORS.length,
|
||||
hair: HAIR_PALETTE.length,
|
||||
shape: HAIR_SHAPES.length,
|
||||
sizeBucket: DNA_SIZE_BUCKETS,
|
||||
};
|
||||
|
||||
export function encodeDna(d: Dna): string {
|
||||
let n = d.skin;
|
||||
n = n * HAIR_PALETTE.length + d.hair;
|
||||
n = n * HAIR_SHAPES.length + d.shape;
|
||||
n = n * DNA_SIZE_BUCKETS + d.sizeBucket;
|
||||
return n.toString(36).padStart(3, '0');
|
||||
}
|
||||
|
||||
export function decodeDna(s: string): Dna | null {
|
||||
if (!/^[0-9a-z]+$/.test(s)) return null;
|
||||
let n = Number.parseInt(s, 36);
|
||||
if (Number.isNaN(n)) return null;
|
||||
const sizeBucket = n % DNA_SIZE_BUCKETS;
|
||||
n = Math.floor(n / DNA_SIZE_BUCKETS);
|
||||
const shape = n % HAIR_SHAPES.length;
|
||||
n = Math.floor(n / HAIR_SHAPES.length);
|
||||
const hair = n % HAIR_PALETTE.length;
|
||||
n = Math.floor(n / HAIR_PALETTE.length);
|
||||
const skin = n;
|
||||
if (skin >= SKIN_COLORS.length) return null;
|
||||
return { skin, hair, shape, sizeBucket };
|
||||
}
|
||||
|
||||
export function dnaFromPerson(p: {
|
||||
skinColor: string;
|
||||
hairColor: string;
|
||||
hairShape: HairShape;
|
||||
size: number;
|
||||
}): Dna {
|
||||
const skin = Math.max(0, SKIN_COLORS.indexOf(p.skinColor as (typeof SKIN_COLORS)[number]));
|
||||
const hair = Math.max(
|
||||
0,
|
||||
HAIR_PALETTE.findIndex((h) => h.color === p.hairColor),
|
||||
);
|
||||
const shape = Math.max(0, HAIR_SHAPES.indexOf(p.hairShape));
|
||||
const t = (p.size - DNA_MIN_SIZE) / (DNA_MAX_SIZE - DNA_MIN_SIZE);
|
||||
const sizeBucket = clampInt(Math.round(t * (DNA_SIZE_BUCKETS - 1)), 0, DNA_SIZE_BUCKETS - 1);
|
||||
return { skin, hair, shape, sizeBucket };
|
||||
}
|
||||
|
||||
export interface DnaAttributes {
|
||||
skinColor: string;
|
||||
hairColor: string;
|
||||
hairShape: HairShape;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function dnaToAttributes(d: Dna): DnaAttributes {
|
||||
const size =
|
||||
DNA_MIN_SIZE + (d.sizeBucket / (DNA_SIZE_BUCKETS - 1)) * (DNA_MAX_SIZE - DNA_MIN_SIZE);
|
||||
return {
|
||||
skinColor: SKIN_COLORS[d.skin],
|
||||
hairColor: HAIR_PALETTE[d.hair].color,
|
||||
hairShape: HAIR_SHAPES[d.shape],
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.floor {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './floor.module.css';
|
||||
import { getScale } from './scaling';
|
||||
|
||||
// Single 2:1 herringbone floor. The smallest axis-aligned square that tiles the
|
||||
// pattern by translation alone is 4W × 4W, where W is the plank's short side.
|
||||
// Some planks straddle the tile boundary; we draw those as full rectangles that
|
||||
// run past the viewBox. SVG clips them, and the neighboring tiles (same content,
|
||||
// shifted by one tile) supply the missing halves.
|
||||
|
||||
const PLANK_W_DESIGN = 60;
|
||||
const PLANK_RATIO = 2;
|
||||
|
||||
interface Plank {
|
||||
x: number;
|
||||
y: number;
|
||||
orient: 'h' | 'v';
|
||||
}
|
||||
|
||||
const PLANKS: ReadonlyArray<Plank> = [
|
||||
{ x: 0, y: 0, orient: 'v' },
|
||||
{ x: 1, y: 1, orient: 'h' },
|
||||
{ x: 3, y: 1, orient: 'v' },
|
||||
{ x: 1, y: -1, orient: 'v' }, // wraps bottom
|
||||
{ x: 2, y: 0, orient: 'h' },
|
||||
{ x: 0, y: 2, orient: 'h' },
|
||||
{ x: -1, y: 3, orient: 'h' }, // wraps left
|
||||
{ x: 2, y: 2, orient: 'v' },
|
||||
{ x: 3, y: 3, orient: 'h' }, // wraps right
|
||||
{ x: 1, y: 3, orient: 'v' }, // wraps top
|
||||
];
|
||||
|
||||
const PLANK_COLOR = '#f0e8d8';
|
||||
const GROUT_COLOR = '#dfd5c0';
|
||||
const PATTERN_ROTATION_DEG = 45;
|
||||
|
||||
function useWindowScale(): number {
|
||||
const [scale, setScale] = useState(() => getScale(window.innerWidth, window.innerHeight));
|
||||
useEffect(() => {
|
||||
const onResize = () => setScale(getScale(window.innerWidth, window.innerHeight));
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
return scale;
|
||||
}
|
||||
|
||||
export function Floor() {
|
||||
const scale = useWindowScale();
|
||||
const W = PLANK_W_DESIGN * scale;
|
||||
const L = W * PLANK_RATIO;
|
||||
const tile = 4 * W;
|
||||
const strokeWidth = Math.max(0.5, W * 0.04);
|
||||
|
||||
function rect(plank: Plank): { x: number; y: number; w: number; h: number } {
|
||||
const x = plank.x * W;
|
||||
const y = plank.y * W;
|
||||
if (plank.orient === 'h') return { x, y, w: L, h: W };
|
||||
return { x, y, w: W, h: L };
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className={styles.floor} aria-hidden="true">
|
||||
<defs>
|
||||
{/* Coarse, darker noise: big blotchy tone variation, like uneven aging
|
||||
* in the wood. Alpha is full noise; opacity lives on the consuming
|
||||
* <rect> so it stays easy to tune. */}
|
||||
<filter id="crowd-floor-noise-coarse" x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.12"
|
||||
numOctaves="2"
|
||||
seed="7"
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.32
|
||||
0 0 0 0 0.24
|
||||
0 0 0 0 0.16
|
||||
1 0 0 0 0"
|
||||
/>
|
||||
</filter>
|
||||
|
||||
{/* Fine, lighter noise: high-frequency grain on top, in warm whites. */}
|
||||
<filter id="crowd-floor-noise-fine" x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.5"
|
||||
numOctaves="2"
|
||||
seed="3"
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.99
|
||||
0 0 0 0 0.98
|
||||
0 0 0 0 0.94
|
||||
1 0 0 0 0"
|
||||
/>
|
||||
</filter>
|
||||
|
||||
{/* Plank seams only, drawn last so the grout lines stay crisp over both
|
||||
* noise layers. */}
|
||||
<pattern
|
||||
id="crowd-floor-seams"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width={tile}
|
||||
height={tile}
|
||||
viewBox={`0 0 ${tile} ${tile}`}
|
||||
patternTransform={`rotate(${PATTERN_ROTATION_DEG})`}
|
||||
>
|
||||
{PLANKS.map((plank) => {
|
||||
const r = rect(plank);
|
||||
return (
|
||||
<rect
|
||||
key={`${plank.x},${plank.y},${plank.orient}`}
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.w}
|
||||
height={r.h}
|
||||
fill="none"
|
||||
stroke={GROUT_COLOR}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Render order: flat colour → coarse noise → fine noise → seams. */}
|
||||
<rect width="100%" height="100%" fill={PLANK_COLOR} />
|
||||
<rect width="100%" height="100%" filter="url(#crowd-floor-noise-coarse)" opacity="0.1" />
|
||||
<rect width="100%" height="100%" filter="url(#crowd-floor-noise-fine)" opacity="0.2" />
|
||||
<rect width="100%" height="100%" fill="url(#crowd-floor-seams)" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { type Dna, decodeDna, encodeDna } from '../dna';
|
||||
|
||||
// Two-way binding between window.location.hash and a Dna value. A non-empty
|
||||
// hash that decodes puts the app in "customize mode"; the returned setter
|
||||
// writes back to the hash so external listeners and the back button stay in
|
||||
// sync. Clearing the DNA strips the hash via history.replaceState so no stray
|
||||
// "#" is left in the URL.
|
||||
export function useDnaHash(): [Dna | null, (next: Dna | null) => void] {
|
||||
const [dna, setDnaState] = useState<Dna | null>(() => readHash());
|
||||
|
||||
const setDna = useCallback((next: Dna | null) => {
|
||||
setDnaState(next);
|
||||
if (next) {
|
||||
const encoded = encodeDna(next);
|
||||
if (window.location.hash.slice(1) !== encoded) {
|
||||
window.location.hash = encoded;
|
||||
}
|
||||
} else if (window.location.hash) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setDnaState(readHash());
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => window.removeEventListener('hashchange', onChange);
|
||||
}, []);
|
||||
|
||||
return [dna, setDna];
|
||||
}
|
||||
|
||||
function readHash(): Dna | null {
|
||||
const raw = window.location.hash.slice(1);
|
||||
return raw ? decodeDna(raw) : null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Crowd Clock</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: #bdb3a4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { CrowdClock } from './crowd-clock';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<CrowdClock />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
// Variation palettes and their sampling weights. Skin is uniform; hair color
|
||||
// and shape are weighted to roughly match real-world frequencies. `bald` is a
|
||||
// shape, not a hair color, since a bald head has no hair to color.
|
||||
|
||||
export const SKIN_COLORS = [
|
||||
'#f1d4a8',
|
||||
'#dab48b',
|
||||
'#bf9670',
|
||||
'#a07857',
|
||||
'#7a563d',
|
||||
'#553a26',
|
||||
] as const;
|
||||
|
||||
export interface HairColor {
|
||||
color: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export const HAIR_PALETTE: readonly HairColor[] = [
|
||||
{ color: '#1a1410', name: 'black', weight: 0.43 },
|
||||
{ color: '#5a3a1f', name: 'brown', weight: 0.32 },
|
||||
{ color: '#d6b870', name: 'blond', weight: 0.14 },
|
||||
{ color: '#a64b2a', name: 'red', weight: 0.03 },
|
||||
{ color: '#b5b1a8', name: 'gray', weight: 0.08 },
|
||||
];
|
||||
|
||||
export const HAIR_SHAPES = ['convex', 'concave', 'parted', 'balding', 'bald'] as const;
|
||||
export type HairShape = (typeof HAIR_SHAPES)[number];
|
||||
|
||||
export const HAIR_SHAPE_WEIGHTS: Record<HairShape, number> = {
|
||||
convex: 0.32,
|
||||
concave: 0.28,
|
||||
parted: 0.28,
|
||||
balding: 0.06,
|
||||
bald: 0.06,
|
||||
};
|
||||
|
||||
export function pickWeighted<T>(items: readonly { weight: number }[], values: readonly T[]): T {
|
||||
let r = Math.random();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
r -= items[i].weight;
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
}
|
||||
|
||||
export function pickSkin(): string {
|
||||
return SKIN_COLORS[Math.floor(Math.random() * SKIN_COLORS.length)];
|
||||
}
|
||||
|
||||
export function pickHair(): string {
|
||||
return pickWeighted(
|
||||
HAIR_PALETTE,
|
||||
HAIR_PALETTE.map((h) => h.color),
|
||||
);
|
||||
}
|
||||
|
||||
export function pickHairShape(): HairShape {
|
||||
return pickWeighted(
|
||||
HAIR_SHAPES.map((s) => ({ weight: HAIR_SHAPE_WEIGHTS[s] })),
|
||||
HAIR_SHAPES,
|
||||
);
|
||||
}
|
||||
|
||||
export function hairName(color: string): string {
|
||||
return HAIR_PALETTE.find((h) => h.color === color)?.name ?? '?';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
ANIM_MIN_SPEED,
|
||||
ARM_SWING_FACTOR,
|
||||
HAIRLINE_PARAMS,
|
||||
LIMB_RADIUS_FACTOR,
|
||||
PERSON_RADIUS,
|
||||
SHOULDER_OFFSET_FACTOR,
|
||||
STROKE_COLOR,
|
||||
STROKE_FRACTION,
|
||||
STROKE_MIN_PX,
|
||||
WALK_SPEED_MAX,
|
||||
} from './constants';
|
||||
import type { Person } from './simulation';
|
||||
|
||||
export function drawPerson(c2d: CanvasRenderingContext2D, p: Person, scale: number): void {
|
||||
const r = PERSON_RADIUS * scale * p.size;
|
||||
const limb = r * LIMB_RADIUS_FACTOR;
|
||||
const facingX = Math.cos(p.facing);
|
||||
const facingY = Math.sin(p.facing);
|
||||
const sideX = -facingY;
|
||||
const sideY = facingX;
|
||||
|
||||
const sSpeed = Math.hypot(p.svx, p.svy);
|
||||
const animMin = ANIM_MIN_SPEED * scale;
|
||||
const speedMax = WALK_SPEED_MAX * scale;
|
||||
const amp = clamp01((sSpeed - animMin) / (speedMax - animMin));
|
||||
|
||||
const swingRight = Math.sin(p.gaitPhase) * amp * r * ARM_SWING_FACTOR;
|
||||
const swingLeft = -swingRight;
|
||||
const shoulder = r * SHOULDER_OFFSET_FACTOR;
|
||||
|
||||
c2d.strokeStyle = STROKE_COLOR;
|
||||
c2d.lineWidth = Math.max(STROKE_MIN_PX * scale, r * STROKE_FRACTION);
|
||||
c2d.lineJoin = 'round';
|
||||
|
||||
// Arms first; the head is drawn on top so arms only show outside the head silhouette.
|
||||
c2d.fillStyle = p.skinColor;
|
||||
drawCircle(
|
||||
c2d,
|
||||
p.x + sideX * shoulder + facingX * swingRight,
|
||||
p.y + sideY * shoulder + facingY * swingRight,
|
||||
limb,
|
||||
);
|
||||
drawCircle(
|
||||
c2d,
|
||||
p.x - sideX * shoulder + facingX * swingLeft,
|
||||
p.y - sideY * shoulder + facingY * swingLeft,
|
||||
limb,
|
||||
);
|
||||
|
||||
// Head and hair are drawn in head-local coords (facing toward +x) so the
|
||||
// hairline math reads naturally.
|
||||
c2d.save();
|
||||
c2d.translate(p.x, p.y);
|
||||
c2d.rotate(p.facing);
|
||||
|
||||
c2d.fillStyle = p.skinColor;
|
||||
c2d.beginPath();
|
||||
c2d.arc(0, 0, r, 0, Math.PI * 2);
|
||||
c2d.fill();
|
||||
c2d.stroke();
|
||||
|
||||
if (p.hairShape !== 'bald') {
|
||||
c2d.fillStyle = p.hairColor;
|
||||
c2d.beginPath();
|
||||
if (p.hairShape === 'balding') {
|
||||
traceHorseshoe(c2d, r);
|
||||
} else {
|
||||
traceFringe(c2d, r, p.hairShape);
|
||||
}
|
||||
c2d.fill();
|
||||
c2d.stroke();
|
||||
}
|
||||
|
||||
c2d.restore();
|
||||
}
|
||||
|
||||
function drawCircle(c2d: CanvasRenderingContext2D, x: number, y: number, r: number): void {
|
||||
c2d.beginPath();
|
||||
c2d.arc(x, y, r, 0, Math.PI * 2);
|
||||
c2d.fill();
|
||||
c2d.stroke();
|
||||
}
|
||||
|
||||
// Hairline that wraps the back of the head and closes with a quadratic curve at
|
||||
// the front (convex, concave, parted). The curve starts and ends where the hair
|
||||
// meets the head perimeter at ±halfAngle.
|
||||
function traceFringe(
|
||||
c2d: CanvasRenderingContext2D,
|
||||
r: number,
|
||||
shape: 'convex' | 'concave' | 'parted',
|
||||
): void {
|
||||
const params = HAIRLINE_PARAMS[shape];
|
||||
const theta = params.halfAngle;
|
||||
const ix = r * Math.cos(theta);
|
||||
const iy = r * Math.sin(theta);
|
||||
c2d.moveTo(ix, iy);
|
||||
c2d.arc(0, 0, r, theta, Math.PI * 2 - theta);
|
||||
if (shape === 'parted') {
|
||||
const partX = ix - r * HAIRLINE_PARAMS.parted.partInset;
|
||||
c2d.quadraticCurveTo(r * params.apex, -iy * 0.55, partX, 0);
|
||||
c2d.quadraticCurveTo(r * params.apex, iy * 0.55, ix, iy);
|
||||
} else {
|
||||
c2d.quadraticCurveTo(r * params.apex, 0, ix, iy);
|
||||
}
|
||||
c2d.closePath();
|
||||
}
|
||||
|
||||
// Horseshoe of hair: an outer arc along the back and sides, plus an inner
|
||||
// ellipse cutting out the bald crown. The front transitions are quadratic
|
||||
// curves whose control points sit on the inner ellipse's tangent at each
|
||||
// intersection, so the join is rounded at the inner end and sharp at the outer.
|
||||
function traceHorseshoe(c2d: CanvasRenderingContext2D, r: number): void {
|
||||
const { outerHalfAngle, innerHalfAngle, innerR, innerStretch, taper } = HAIRLINE_PARAMS.balding;
|
||||
const ry = innerR * r;
|
||||
const rx = ry * innerStretch;
|
||||
const cosB = Math.cos(innerHalfAngle);
|
||||
const sinB = Math.sin(innerHalfAngle);
|
||||
const ouX = r * Math.cos(outerHalfAngle);
|
||||
const ouY = r * Math.sin(outerHalfAngle);
|
||||
const iuX = rx * cosB;
|
||||
const iuY = ry * sinB;
|
||||
|
||||
// λ scales the (rx·sinβ, ry·cosβ) tangent so the bezier control point sits
|
||||
// r·taper away along the tangent direction, independent of stretch.
|
||||
const tangentMag = Math.hypot(rx * sinB, ry * cosB);
|
||||
const lambda = (r * taper) / tangentMag;
|
||||
const cpLowerX = iuX + lambda * rx * sinB;
|
||||
const cpLowerY = -iuY + lambda * ry * cosB;
|
||||
const cpUpperX = iuX + lambda * rx * sinB;
|
||||
const cpUpperY = iuY - lambda * ry * cosB;
|
||||
|
||||
c2d.moveTo(ouX, ouY);
|
||||
c2d.arc(0, 0, r, outerHalfAngle, Math.PI * 2 - outerHalfAngle);
|
||||
c2d.quadraticCurveTo(cpLowerX, cpLowerY, iuX, -iuY);
|
||||
c2d.ellipse(0, 0, rx, ry, 0, Math.PI * 2 - innerHalfAngle, innerHalfAngle, true);
|
||||
c2d.quadraticCurveTo(cpUpperX, cpUpperY, ouX, ouY);
|
||||
c2d.closePath();
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Maps the design-size constants to the current viewport. Lengths, forces, and
|
||||
// speeds are multiplied by `scale = min(w, h) / DESIGN_MIN_DIM` where they're
|
||||
// used. Population counts come from clock geometry and the configured
|
||||
// complyer/wanderer density.
|
||||
|
||||
import {
|
||||
CLOCK_RADIUS_FRACTION,
|
||||
DESIGN_COMPLYERS,
|
||||
DESIGN_MIN_DIM,
|
||||
DESIGN_WANDERER_DENSITY,
|
||||
} from './constants';
|
||||
|
||||
export function getScale(width: number, height: number): number {
|
||||
return Math.min(width, height) / DESIGN_MIN_DIM;
|
||||
}
|
||||
|
||||
export interface Population {
|
||||
complyers: number;
|
||||
wanderers: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function getPopulation(width: number, height: number): Population {
|
||||
const R = Math.min(width, height) * CLOCK_RADIUS_FRACTION;
|
||||
const wingArea = Math.max(0, width * height - Math.PI * R * R);
|
||||
const wingAreaOverR2 = wingArea / (R * R);
|
||||
const wanderers = Math.max(0, Math.round(DESIGN_WANDERER_DENSITY * wingAreaOverR2));
|
||||
return { complyers: DESIGN_COMPLYERS, wanderers, total: DESIGN_COMPLYERS + wanderers };
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { buildAttractors, closestPointOnSegment } from './attractors';
|
||||
import {
|
||||
ANIM_MIN_SPEED,
|
||||
ANIM_VELOCITY_SMOOTHING,
|
||||
ATTRACTOR_STRENGTH,
|
||||
BASE_GAIT_FREQUENCY,
|
||||
EDGE_BIAS_STRENGTH,
|
||||
EDGE_BIAS_ZONE_FRACTION,
|
||||
FACING_SMOOTHING,
|
||||
FACING_SPEED_THRESHOLD,
|
||||
PERSON_SIZE_VARIATION,
|
||||
REPULSION_FAR_RADIUS,
|
||||
REPULSION_FAR_STRENGTH,
|
||||
REPULSION_RADIUS,
|
||||
REPULSION_STRENGTH,
|
||||
ROLE_SWITCH_RATE,
|
||||
TEMPERATURE,
|
||||
VELOCITY_DAMPING,
|
||||
WALK_SPEED_MAX,
|
||||
WANDERER_BOUND_PADDING,
|
||||
WANDERER_BOUND_STRENGTH,
|
||||
WANDERER_MIN_DISTANCE,
|
||||
WANDERER_PRESSURE_SCALE,
|
||||
WANDERER_PRESSURE_STRENGTH,
|
||||
WANDERER_SEPARATION_RATE,
|
||||
} from './constants';
|
||||
import { type HairShape, pickHair, pickHairShape, pickSkin } from './palette';
|
||||
|
||||
export type Role = 'wanderer' | 'complyer';
|
||||
|
||||
export interface Person {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
// Smoothed (low-passed) velocity, used to drive gait and facing so they
|
||||
// follow sustained motion instead of per-frame force jitter.
|
||||
svx: number;
|
||||
svy: number;
|
||||
role: Role;
|
||||
size: number; // multiplier on PERSON_RADIUS
|
||||
facing: number;
|
||||
gaitPhase: number; // radians
|
||||
skinColor: string;
|
||||
hairColor: string;
|
||||
hairShape: HairShape;
|
||||
}
|
||||
|
||||
export function createPerson(width: number, height: number, role: Role, scale: number): Person {
|
||||
const margin = WANDERER_BOUND_PADDING * scale;
|
||||
return {
|
||||
x: margin + Math.random() * (width - 2 * margin),
|
||||
y: margin + Math.random() * (height - 2 * margin),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
svx: 0,
|
||||
svy: 0,
|
||||
role,
|
||||
size: 1 + (Math.random() - 0.5) * PERSON_SIZE_VARIATION,
|
||||
facing: Math.random() * Math.PI * 2,
|
||||
gaitPhase: Math.random() * Math.PI * 2,
|
||||
skinColor: pickSkin(),
|
||||
hairColor: pickHair(),
|
||||
hairShape: pickHairShape(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimContext {
|
||||
width: number;
|
||||
height: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
clockR: number;
|
||||
// min(w,h) / DESIGN_MIN_DIM; scales every length/force/speed constant.
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function stepSimulation(people: Person[], dt: number, ctx: SimContext): void {
|
||||
const attractors = buildAttractors(ctx.centerX, ctx.centerY, ctx.clockR, ctx.scale, new Date());
|
||||
|
||||
swapRoles(people, dt, ctx);
|
||||
integrateForces(people, attractors, dt, ctx);
|
||||
enforceWandererSeparation(people, ctx.scale);
|
||||
}
|
||||
|
||||
// Role turnover by 1-for-1 swap. Each wanderer rolls a position-dependent flip
|
||||
// chance; on a hit it becomes a complyer and a random complyer becomes a
|
||||
// wanderer the same step. Counts stay exact: the startup ratio holds forever.
|
||||
//
|
||||
// A complyer flipped to wanderer at index > i can be hit again by the same loop
|
||||
// this frame. At ROLE_SWITCH_RATE that's negligible (~1e-4 per frame per
|
||||
// wanderer); if the rate ever climbs, snapshot the wanderer indices first.
|
||||
function swapRoles(people: Person[], dt: number, ctx: SimContext): void {
|
||||
let complyerCount = 0;
|
||||
for (const p of people) if (p.role === 'complyer') complyerCount++;
|
||||
if (complyerCount === 0) return;
|
||||
|
||||
// Per-axis bias zone widths: the outer EDGE_BIAS_ZONE_FRACTION of the
|
||||
// empty space between the clock and each viewport edge.
|
||||
const biasZoneX = Math.max(0, ctx.width / 2 - ctx.clockR) * EDGE_BIAS_ZONE_FRACTION;
|
||||
const biasZoneY = Math.max(0, ctx.height / 2 - ctx.clockR) * EDGE_BIAS_ZONE_FRACTION;
|
||||
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const p = people[i];
|
||||
if (p.role !== 'wanderer') continue;
|
||||
|
||||
const edgeDistX = Math.min(p.x, ctx.width - p.x);
|
||||
const edgeDistY = Math.min(p.y, ctx.height - p.y);
|
||||
const proxX = biasZoneX > 0 ? Math.max(0, 1 - edgeDistX / biasZoneX) : 0;
|
||||
const proxY = biasZoneY > 0 ? Math.max(0, 1 - edgeDistY / biasZoneY) : 0;
|
||||
const edgeProx = Math.max(proxX, proxY);
|
||||
const flipRate = ROLE_SWITCH_RATE * (1 + edgeProx * EDGE_BIAS_STRENGTH);
|
||||
if (Math.random() >= flipRate * dt) continue;
|
||||
|
||||
// Pick the Nth complyer uniformly by linear scan. O(N) per swap, fine at
|
||||
// this rate.
|
||||
let target = Math.floor(Math.random() * complyerCount);
|
||||
for (const q of people) {
|
||||
if (q.role !== 'complyer') continue;
|
||||
if (target === 0) {
|
||||
p.role = 'complyer';
|
||||
q.role = 'wanderer';
|
||||
break;
|
||||
}
|
||||
target--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spatial hash for the pairwise repulsion loop. Cells are sized to the
|
||||
// long-range repulsion radius (at the current scale), so a person's potential
|
||||
// partners always lie in the 3×3 block around their cell. Buckets live at
|
||||
// module level and are reused across frames.
|
||||
let bucketGrid: number[][] = [];
|
||||
let bucketCols = 0;
|
||||
let bucketRows = 0;
|
||||
|
||||
function getBucketGrid(cols: number, rows: number): number[][] {
|
||||
if (cols !== bucketCols || rows !== bucketRows) {
|
||||
bucketCols = cols;
|
||||
bucketRows = rows;
|
||||
bucketGrid = Array.from({ length: cols * rows }, () => []);
|
||||
} else {
|
||||
for (const bucket of bucketGrid) bucket.length = 0;
|
||||
}
|
||||
return bucketGrid;
|
||||
}
|
||||
|
||||
function integrateForces(
|
||||
people: Person[],
|
||||
attractors: ReturnType<typeof buildAttractors>,
|
||||
dt: number,
|
||||
ctx: SimContext,
|
||||
): void {
|
||||
const N = people.length;
|
||||
const s = ctx.scale;
|
||||
const damp = Math.exp(-VELOCITY_DAMPING * dt);
|
||||
const animSmoothing = 1 - Math.exp(-ANIM_VELOCITY_SMOOTHING * dt);
|
||||
const facingSmoothing = 1 - Math.exp(-FACING_SMOOTHING * dt);
|
||||
const repulsionRadius = REPULSION_RADIUS * s;
|
||||
const repulsionFarRadius = REPULSION_FAR_RADIUS * s;
|
||||
const repulsionRadius2 = repulsionRadius * repulsionRadius;
|
||||
const repulsionFarRadius2 = repulsionFarRadius * repulsionFarRadius;
|
||||
const repulsionStrength = REPULSION_STRENGTH * s;
|
||||
const repulsionFarStrength = REPULSION_FAR_STRENGTH * s;
|
||||
const attractorStrength = ATTRACTOR_STRENGTH * s;
|
||||
const wandererPressureStrength = WANDERER_PRESSURE_STRENGTH * s;
|
||||
const wandererPressureScale = WANDERER_PRESSURE_SCALE * s;
|
||||
const wandererBoundPadding = WANDERER_BOUND_PADDING * s;
|
||||
const walkSpeedMax = WALK_SPEED_MAX * s;
|
||||
const temperature = TEMPERATURE * s;
|
||||
const cellSize = repulsionFarRadius;
|
||||
|
||||
const fxs = new Float64Array(N);
|
||||
const fys = new Float64Array(N);
|
||||
|
||||
// Phase 1: per-person forces (attractor / wanderer drive / bounds / jitter).
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = people[i];
|
||||
let fx = 0;
|
||||
let fy = 0;
|
||||
if (p.role === 'complyer') {
|
||||
// Inverse-square law with a soft core (`falloff +` avoids the singularity
|
||||
// at d=0), so it's always nonzero and the field has no dead zones.
|
||||
// magnitude = STRENGTH · falloff² / (falloff + d)²
|
||||
for (const seg of attractors) {
|
||||
const cp = closestPointOnSegment(p.x, p.y, seg);
|
||||
if (cp.d < 0.001) continue;
|
||||
const ratio = seg.falloff / (seg.falloff + cp.d);
|
||||
const w = ratio * ratio;
|
||||
const invD = 1 / cp.d;
|
||||
fx += attractorStrength * (cp.x - p.x) * invD * w;
|
||||
fy += attractorStrength * (cp.y - p.y) * invD * w;
|
||||
}
|
||||
} else {
|
||||
// Wanderers: viewport bound plus a long-range "crowd pressure", a
|
||||
// soft-core inverse-square push from every other person. The push is
|
||||
// deliberately uncapped: any distance cutoff brings back dead zones where
|
||||
// a wanderer stalls. The O(W·N) cost buys a direction that always points
|
||||
// away from the crowd.
|
||||
const minX = wandererBoundPadding;
|
||||
const maxX = ctx.width - wandererBoundPadding;
|
||||
const minY = wandererBoundPadding;
|
||||
const maxY = ctx.height - wandererBoundPadding;
|
||||
if (p.x < minX) fx += (minX - p.x) * WANDERER_BOUND_STRENGTH;
|
||||
else if (p.x > maxX) fx -= (p.x - maxX) * WANDERER_BOUND_STRENGTH;
|
||||
if (p.y < minY) fy += (minY - p.y) * WANDERER_BOUND_STRENGTH;
|
||||
else if (p.y > maxY) fy -= (p.y - maxY) * WANDERER_BOUND_STRENGTH;
|
||||
|
||||
for (let j = 0; j < people.length; j++) {
|
||||
if (j === i) continue;
|
||||
const q = people[j];
|
||||
const dx = p.x - q.x;
|
||||
const dy = p.y - q.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < 1) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const ratio = wandererPressureScale / (wandererPressureScale + d);
|
||||
const w = ratio * ratio;
|
||||
const invD = 1 / d;
|
||||
fx += wandererPressureStrength * dx * invD * w;
|
||||
fy += wandererPressureStrength * dy * invD * w;
|
||||
}
|
||||
}
|
||||
fx += (Math.random() - 0.5) * 2 * temperature;
|
||||
fy += (Math.random() - 0.5) * 2 * temperature;
|
||||
fxs[i] = fx;
|
||||
fys[i] = fy;
|
||||
}
|
||||
|
||||
// Phase 2: pairwise repulsion via spatial hash. Bucket people by cell, then
|
||||
// for each person look only at the 3×3 block around them. Each pair (i, j)
|
||||
// with j > i is visited once and the force applied to both.
|
||||
const cols = Math.max(1, Math.ceil(ctx.width / cellSize));
|
||||
const rows = Math.max(1, Math.ceil(ctx.height / cellSize));
|
||||
const buckets = getBucketGrid(cols, rows);
|
||||
const cellX = new Int16Array(N);
|
||||
const cellY = new Int16Array(N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = people[i];
|
||||
const cx = clampInt(Math.floor(p.x / cellSize), 0, cols - 1);
|
||||
const cy = clampInt(Math.floor(p.y / cellSize), 0, rows - 1);
|
||||
cellX[i] = cx;
|
||||
cellY[i] = cy;
|
||||
buckets[cy * cols + cx].push(i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = people[i];
|
||||
const cx = cellX[i];
|
||||
const cy = cellY[i];
|
||||
const yMin = Math.max(0, cy - 1);
|
||||
const yMax = Math.min(rows - 1, cy + 1);
|
||||
const xMin = Math.max(0, cx - 1);
|
||||
const xMax = Math.min(cols - 1, cx + 1);
|
||||
for (let ny = yMin; ny <= yMax; ny++) {
|
||||
for (let nx = xMin; nx <= xMax; nx++) {
|
||||
const bucket = buckets[ny * cols + nx];
|
||||
for (let k = 0; k < bucket.length; k++) {
|
||||
const j = bucket[k];
|
||||
if (j <= i) continue;
|
||||
const q = people[j];
|
||||
const dx = p.x - q.x;
|
||||
const dy = p.y - q.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 >= repulsionFarRadius2 || d2 === 0) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
let force = repulsionFarStrength * (1 - d / repulsionFarRadius);
|
||||
if (d2 < repulsionRadius2) {
|
||||
force += repulsionStrength * (1 - d / repulsionRadius);
|
||||
}
|
||||
const ux = (dx / d) * force;
|
||||
const uy = (dy / d) * force;
|
||||
fxs[i] += ux;
|
||||
fys[i] += uy;
|
||||
fxs[j] -= ux;
|
||||
fys[j] -= uy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: integrate velocity & position, then advance animation state.
|
||||
for (let i = 0; i < N; i++) {
|
||||
const p = people[i];
|
||||
p.vx = (p.vx + fxs[i] * dt) * damp;
|
||||
p.vy = (p.vy + fys[i] * dt) * damp;
|
||||
|
||||
const v = Math.hypot(p.vx, p.vy);
|
||||
if (v > walkSpeedMax) {
|
||||
p.vx = (p.vx / v) * walkSpeedMax;
|
||||
p.vy = (p.vy / v) * walkSpeedMax;
|
||||
}
|
||||
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
|
||||
p.svx += (p.vx - p.svx) * animSmoothing;
|
||||
p.svy += (p.vy - p.svy) * animSmoothing;
|
||||
advanceGait(p, dt, facingSmoothing, s);
|
||||
}
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function advanceGait(p: Person, dt: number, facingSmoothing: number, scale: number): void {
|
||||
const sSpeed = Math.hypot(p.svx, p.svy);
|
||||
const animMinSpeed = ANIM_MIN_SPEED * scale;
|
||||
const walkSpeedMax = WALK_SPEED_MAX * scale;
|
||||
const normSpeed = clamp01((sSpeed - animMinSpeed) / (walkSpeedMax - animMinSpeed));
|
||||
p.gaitPhase += BASE_GAIT_FREQUENCY * 2 * Math.PI * normSpeed * dt;
|
||||
if (sSpeed > FACING_SPEED_THRESHOLD * scale) {
|
||||
const target = Math.atan2(p.svy, p.svx);
|
||||
let diff = target - p.facing;
|
||||
while (diff > Math.PI) diff -= 2 * Math.PI;
|
||||
while (diff < -Math.PI) diff += 2 * Math.PI;
|
||||
p.facing += diff * facingSmoothing;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Direct positional separation that bypasses the speed cap, letting wanderers
|
||||
// shove through clusters faster than the cap would otherwise allow.
|
||||
//
|
||||
// Naive O(N²) pass that skips Phase 2's spatial hash. At ~240 people the cost
|
||||
// is trivial; if the population ever grows enough to matter, move this onto the
|
||||
// same bucket grid the repulsion pass builds.
|
||||
function enforceWandererSeparation(people: Person[], scale: number): void {
|
||||
const minDist = WANDERER_MIN_DISTANCE * scale;
|
||||
const minD2 = minDist * minDist;
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const p = people[i];
|
||||
for (let j = i + 1; j < people.length; j++) {
|
||||
const q = people[j];
|
||||
if (p.role !== 'wanderer' && q.role !== 'wanderer') continue;
|
||||
const dx = p.x - q.x;
|
||||
const dy = p.y - q.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < minD2 && d2 > 0.0001) {
|
||||
const d = Math.sqrt(d2);
|
||||
const push = (minDist - d) * WANDERER_SEPARATION_RATE * 0.5;
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
p.x += nx * push;
|
||||
p.y += ny * push;
|
||||
q.x -= nx * push;
|
||||
q.y -= ny * push;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user