diff --git a/web/drizzle/0022_atlas-pin-kind.sql b/web/drizzle/0022_atlas-pin-kind.sql new file mode 100644 index 0000000..2452ff2 --- /dev/null +++ b/web/drizzle/0022_atlas-pin-kind.sql @@ -0,0 +1 @@ +ALTER TABLE `atlas_pins` ADD `kind` text DEFAULT 'pin' NOT NULL; diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index af5ad77..fdc1b81 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1786860000000, "tag": "0021_pattern-performances", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1786880000000, + "tag": "0022_atlas-pin-kind", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/web/src/app/api/community/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index f8ccf57..8749b55 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -9,17 +9,21 @@ import { rateLimit } from "@/lib/community/ratelimit"; import { atlasPins, patterns } from "@/lib/community/schema"; // GET /api/community/atlas — every pattern pinned on the atlas. -// POST — place or move a pin: { patternId, x, y } in the atlas's 0..100 -// data space. Only the pattern's author (or a moderator) may. +// POST — place or move a pin: { patternId, x, y, entryId?, kind? } in the +// atlas's 0..100 data space. Only the pattern's author (or a +// moderator) may. kind "pin" (default) is an exemplar tile on the +// shared map; kind "research" files the pattern against an entry as a +// field note — failures stay on record where they happened, without +// occupying the map. // DELETE — take a pin off the map: { patternId }. Same permission. // // A pin says "this work lives at this spot of pattern space". One per -// pattern; placing again just moves it. +// pattern; placing again just moves it (and can change its kind). export async function GET(request: Request) { const blocked = originBlocked(request); if (blocked) return blocked; - return withCors(request, await handleGet()); + return withCors(request, await handleGet(request)); } export async function POST(request: Request) { @@ -36,17 +40,21 @@ export async function DELETE(request: Request) { export const OPTIONS = preflight; -async function handleGet() { +async function handleGet(request: Request) { if (!communityEnabled()) { return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); } - const pins = await listAtlasPins(); + const session = await getAuth().api.getSession({ headers: request.headers }); + const pins = await listAtlasPins( + session ? { id: session.user.id, isAdmin: isAdminSession(session) } : null, + ); return Response.json({ pins: pins.map((pin) => ({ patternId: pin.patternId, x: pin.x, y: pin.y, entryId: pin.entryId, + kind: pin.kind, title: pin.title, userId: pin.userId, username: pin.username, @@ -107,7 +115,15 @@ async function handleWrite(request: Request, mode: "place" | "remove") { return Response.json({ ok: true }); } - if (pattern.visibility !== "public") { + const kind = payload.kind === undefined ? "pin" : payload.kind; + if (kind !== "pin" && kind !== "research") { + return Response.json({ error: "Unknown pin kind." }, { status: 400 }); + } + + // A map pin is a tile everyone sees, so it must be public. A research row is + // a field note — private failures are allowed, because the read path already + // shows those only to their author and moderators. + if (kind === "pin" && pattern.visibility !== "public") { return Response.json( { error: "Only public patterns can sit on the shared map." }, { status: 400 }, @@ -131,13 +147,22 @@ async function handleWrite(request: Request, mode: "place" | "remove") { return Response.json({ error: "No such point on the map." }, { status: 400 }); } + // A research row exists to remember where an attempt happened — unmoored + // from any entry it would be invisible everywhere, so refuse the no-op. + if (kind === "research" && entryId === null) { + return Response.json( + { error: "Research notes attach to a point — drop it closer to one." }, + { status: 400 }, + ); + } + const now = new Date(); await getDb() .insert(atlasPins) - .values({ patternId, x, y, entryId, updatedAt: now }) + .values({ patternId, x, y, entryId, kind, updatedAt: now }) .onConflictDoUpdate({ target: atlasPins.patternId, - set: { x, y, entryId, updatedAt: now }, + set: { x, y, entryId, kind, updatedAt: now }, }); return Response.json({ ok: true }); diff --git a/web/src/app/community/atlas/page.tsx b/web/src/app/community/atlas/page.tsx index 73afa79..8aebb1a 100644 --- a/web/src/app/community/atlas/page.tsx +++ b/web/src/app/community/atlas/page.tsx @@ -35,16 +35,23 @@ export default async function AtlasPage() { const viewerId = session?.user.id ?? null; const isAdmin = isAdminSession(session); - const pins = await listAtlasPins(); + const pins = await listAtlasPins(viewerId ? { id: viewerId, isAdmin } : null); - // The picker: the viewer's own public patterns that are not on the map yet, - // newest first (listPatternsByUser already orders that way) and carrying - // their code — you pick by looking at the pattern, not by reading a title. + // The picker: the viewer's own patterns that are not on the map yet, newest + // first (listPatternsByUser already orders that way) and carrying their code + // — you pick by looking at the pattern, not by reading a title. Private ones + // ride along flagged: they can only be filed as research notes, never as + // map tiles, and the client routes them there. const pinned = new Set(pins.map((pin) => pin.patternId)); const myPatterns = viewerId ? (await listPatternsByUser(viewerId, viewerId)) - .filter((pattern) => pattern.visibility === "public" && !pinned.has(pattern.id)) - .map((pattern) => ({ id: pattern.id, title: pattern.title, code: pattern.code })) + .filter((pattern) => !pinned.has(pattern.id)) + .map((pattern) => ({ + id: pattern.id, + title: pattern.title, + code: pattern.code, + isPublic: pattern.visibility === "public", + })) : []; return ( @@ -54,6 +61,8 @@ export default async function AtlasPage() { x: pin.x, y: pin.y, entryId: pin.entryId, + kind: pin.kind === "research" ? ("research" as const) : ("pin" as const), + isPublic: pin.visibility === "public", title: pin.title, code: pin.code, userId: pin.userId, diff --git a/web/src/components/community/Atlas.module.css b/web/src/components/community/Atlas.module.css index 98484ed..b002b72 100644 --- a/web/src/components/community/Atlas.module.css +++ b/web/src/components/community/Atlas.module.css @@ -205,6 +205,13 @@ color: var(--pfc-ink); } +/* Private patterns in the picker can only become research notes — the tag + says where the drop will land before anything is dropped. */ +.researchTag { + color: var(--pfc-led, #e8552e); + opacity: 0.85; +} + .pickerHead { font-family: var(--pf-mono, monospace); font-size: 9.5px; diff --git a/web/src/components/community/AtlasClient.tsx b/web/src/components/community/AtlasClient.tsx index fe5bb14..73c1a39 100644 --- a/web/src/components/community/AtlasClient.tsx +++ b/web/src/components/community/AtlasClient.tsx @@ -25,6 +25,7 @@ import { FAMILIES, STATUSES, buildPrompt, + isNewEntry, type AtlasEntry, type AtlasStatusId, } from "@/lib/atlas/data"; @@ -56,7 +57,9 @@ const MAX_ZOOM = 8; const LINK_RADIUS = 80; type Lang = "en" | "ko"; -type Filter = AtlasStatusId | "all"; +/** Status chips, plus "all" and "new" — the latest batch, which cuts across + * statuses and is the only way to find an import in a map this size. */ +type Filter = AtlasStatusId | "all" | "new"; type Selection = { kind: "entry"; id: string } | { kind: "pin"; id: string } | null; export type AtlasPinData = { @@ -64,6 +67,13 @@ export type AtlasPinData = { x: number; y: number; entryId: string | null; + /** "pin" = an exemplar tile on the map. "research" = a field note filed + * against an entry — kept as data, revealed from the entry panel, never + * drawn as a tile. Failures are worth remembering where they happened. */ + kind: "pin" | "research"; + /** Whether the pattern itself is public — a research note is allowed to be + * private, and a private note can never be promoted to a map tile. */ + isPublic: boolean; title: string; code: string; userId: string; @@ -79,6 +89,8 @@ const UI = { copyLayout: "Copy layout", copiedLayout: "Copied (JSON)", allChip: "All", + newChip: (n: number) => `New · ${n}`, + newBadge: "new", pinChip: "Pinned", axisOrder: "ORDER →", axisChaos: "→ CHAOS", @@ -105,6 +117,14 @@ const UI = { madeFrom: "Made from prompt", linkNone: "— none —", patternsFrom: "Patterns from this prompt", + researchFrom: (n: number) => `Research attempts · ${n}`, + researchBadge: "research note", + researchHint: "Kept as data against this point — not drawn on the map.", + fileResearch: "File as research (hide the tile)", + promotePin: "Put on the map as a tile", + placeBannerResearch: (title: string) => + `Filing “${title}” as research — drop it on the point it came from`, + privateTag: "private → research", knobs: "Knobs", addPattern: "Add my pattern", pickerHead: (n: number) => `Your patterns · newest first (${n})`, @@ -117,6 +137,8 @@ const UI = { copyLayout: "배치 복사", copiedLayout: "복사됨 (JSON)", allChip: "전체", + newChip: (n: number) => `새로 추가 · ${n}`, + newBadge: "새로 추가", pinChip: "핀", axisOrder: "질서 ORDER →", axisChaos: "→ 혼돈 CHAOS", @@ -143,6 +165,13 @@ const UI = { madeFrom: "출신 프롬프트", linkNone: "— 없음 —", patternsFrom: "이 프롬프트에서 나온 패턴", + researchFrom: (n: number) => `연구 기록 · ${n}`, + researchBadge: "연구 기록", + researchHint: "이 포인트에 데이터로만 남음 — 지도에는 그려지지 않는다.", + fileResearch: "연구 기록으로 내리기 (타일 숨김)", + promotePin: "지도 타일로 올리기", + placeBannerResearch: (title: string) => `“${title}” 연구 기록 중 — 출신 포인트 위에 놓기`, + privateTag: "비공개 → 연구 기록", knobs: "노브", addPattern: "내 패턴 올리기", pickerHead: (n: number) => `내 패턴 · 최신순 (${n})`, @@ -159,8 +188,10 @@ export default function AtlasClient({ pins: AtlasPinData[]; viewerId: string | null; isAdmin: boolean; - /** The viewer's own public patterns not yet on the map — the picker. */ - myPatterns: Array<{ id: string; title: string; code: string }>; + /** The viewer's own patterns not yet on the map — the picker. Private ones + * can only be filed as research notes, and the placement flow routes them + * there. */ + myPatterns: Array<{ id: string; title: string; code: string; isPublic: boolean }>; }) { const router = useRouter(); // Reading straight from the store (rather than syncing state in an effect) @@ -180,7 +211,12 @@ export default function AtlasClient({ const [view, setView] = useState({ k: 1, tx: 0, ty: 0 }); // "Add my pattern": pick from the toolbar, then click the map to drop. const [pickerOpen, setPickerOpen] = useState(false); - const [placing, setPlacing] = useState<{ id: string; title: string; code: string } | null>(null); + const [placing, setPlacing] = useState<{ + id: string; + title: string; + code: string; + isPublic: boolean; + } | null>(null); // While placing: the entry the cursor would link to (nearest within radius). const [placingNear, setPlacingNear] = useState(null); // The picker lists your own patterns newest first, as thumbnails — you @@ -232,6 +268,10 @@ export default function AtlasClient({ const entryPos = (e: AtlasEntry): [number, number] => positions[e.id] ?? [e.x, e.y]; const canEditPin = (pin: AtlasPinData) => isAdmin || (viewerId !== null && viewerId === pin.userId); + // Only exemplars are drawn on the map; research rows surface from the entry + // panel. Both live in `pins`, so selecting either opens the same panel. + const mapPins = pins.filter((pin) => pin.kind === "pin"); + const selectedEntry = selection?.kind === "entry" ? ENTRIES.find((e) => e.id === selection.id) ?? null : null; const selectedPin = @@ -342,7 +382,7 @@ export default function AtlasClient({ if (!placing || dragMovedRef.current) return; const coords = toDataCoords(ev.clientX, ev.clientY); if (!coords) return; - void placePattern(placing.id, coords); + void placePattern(placing, coords); }; /* ── server writes ── */ @@ -351,7 +391,7 @@ export default function AtlasClient({ await fetch("/api/community/atlas", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId: pin.patternId, x, y, entryId: pin.entryId }), + body: JSON.stringify({ patternId: pin.patternId, x, y, entryId: pin.entryId, kind: pin.kind }), }); router.refresh(); }; @@ -361,25 +401,42 @@ export default function AtlasClient({ await fetch("/api/community/atlas", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId: pin.patternId, x, y, entryId }), + body: JSON.stringify({ patternId: pin.patternId, x, y, entryId, kind: pin.kind }), }); router.refresh(); }; - const placePattern = async (patternId: string, coords: [number, number]) => { + /** Demote a tile to a research note, or promote a note back to a tile. */ + const setPinKind = async (pin: AtlasPinData, kind: "pin" | "research") => { + const [x, y] = pinPos(pin); + await fetch("/api/community/atlas", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ patternId: pin.patternId, x, y, entryId: pin.entryId, kind }), + }); + router.refresh(); + }; + + const placePattern = async (pattern: NonNullable, coords: [number, number]) => { // Dropping NEAR a prompt point is pointing at it — the nearest entry // within LINK_RADIUS becomes the lineage. Dropping in open sea links to // nothing; the pin panel's select stays for corrections either way. + // + // A private pattern files as a research note, and a note without a point + // would be invisible everywhere — so that drop must land near an entry + // (the banner says so; a miss is a no-op, not an error). const entryId = nearestEntry(coords); + const kind = pattern.isPublic ? "pin" : "research"; + if (kind === "research" && !entryId) return; const res = await fetch("/api/community/atlas", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId, x: coords[0], y: coords[1], entryId }), + body: JSON.stringify({ patternId: pattern.id, x: coords[0], y: coords[1], entryId, kind }), }); if (res.ok) { setPlacing(null); setPlacingNear(null); - setSelection({ kind: "pin", id: patternId }); + setSelection({ kind: "pin", id: pattern.id }); router.refresh(); } }; @@ -431,13 +488,18 @@ export default function AtlasClient({ (pattern) => query === "" || pattern.title.toLowerCase().includes(query), ); + const newCount = ENTRIES.filter(isNewEntry).length; const chipList: Array<[Filter, string]> = [ ["all", t.allChip], + ...(newCount > 0 ? ([["new", t.newChip(newCount)]] as Array<[Filter, string]>) : []), ...(Object.keys(STATUSES) as AtlasStatusId[]).map( (s) => [s, statusLabel(s)] as [Filter, string], ), ]; + const passesFilter = (e: AtlasEntry) => + filter === "all" || (filter === "new" ? isNewEntry(e) : e.st === filter); + const worldTransform = `translate(${view.tx} ${view.ty}) scale(${view.k})`; const inv = 1 / view.k; @@ -490,7 +552,12 @@ export default function AtlasClient({ className={styles.pinWell} /> - {pattern.title} + + {pattern.title} + {!pattern.isPublic && ( + {t.privateTag} + )} + ))} @@ -533,7 +600,7 @@ export default function AtlasClient({ {placing && (
- {t.placeBanner(placing.title)} + {placing.isPublic ? t.placeBanner(placing.title) : t.placeBannerResearch(placing.title)} {placingNear && ENTRY_BY_ID.has(placingNear) && ( {" "}→ {entryName(ENTRY_BY_ID.get(placingNear)!)} @@ -612,7 +679,7 @@ export default function AtlasClient({ {/* Lineage threads: a pin hangs from the prompt that made it. */} - {pins.map((pin) => { + {mapPins.map((pin) => { if (!pin.entryId) return null; const entry = ENTRY_BY_ID.get(pin.entryId); if (!entry) return null; @@ -631,12 +698,23 @@ export default function AtlasClient({ })} - {ENTRIES.filter((e) => filter === "all" || e.st === filter).map((e) => { + {ENTRIES.filter(passesFilter).map((e) => { const [px, py] = entryPos(e); const color = FAMILIES[e.f].color; - const dim = e.st === "unexplored" ? 0.45 : e.st === "hold" ? 0.6 : e.st === "invented" ? 0.8 : 1; - const dash = e.st === "unexplored" ? "2.5 2.5" : e.st === "invented" ? "1 3" : undefined; - const glow = e.st === "verified" ? 7 : e.st === "invented" ? 5 : 3; + // Abandoned ground is drawn faintest of all — it stays on the + // chart as a record, not as an invitation. + const dim = + e.st === "retired" ? 0.22 + : e.st === "unexplored" ? 0.55 + : e.st === "hold" ? 0.6 + : e.st === "invented" ? 0.8 + : 1; + const dash = + e.st === "retired" ? "1 4" + : e.st === "unexplored" ? "2.5 2.5" + : e.st === "invented" ? "1 3" + : undefined; + const glow = e.st === "verified" ? 7 : e.st === "invented" ? 5 : e.st === "retired" ? 0 : 3; const isSelected = selection?.kind === "entry" && selection.id === e.id; return ( 0 ? { filter: `drop-shadow(0 0 ${glow}px ${color})` } : undefined} /> - {entryName(e)} + + {entryName(e)} + ); })} - {pins.map((pin) => { + {mapPins.map((pin) => { const [px, py] = pinPos(pin); const isSelected = selection?.kind === "pin" && selection.id === pin.patternId; const draggable = editMode && canEditPin(pin); @@ -752,6 +836,13 @@ export default function AtlasClient({
{t.pinBy} {selectedPin.displayUsername ?? selectedPin.username ?? "?"}
+ {selectedPin.kind === "research" && ( +
+ + {t.researchBadge} + +
+ )}
{t.madeFrom}
@@ -761,7 +852,9 @@ export default function AtlasClient({ value={selectedPin.entryId ?? ""} onChange={(ev) => void setPinLink(selectedPin, ev.target.value || null)} > - + {/* A research note without a point would be invisible + everywhere, so that one option disappears for them. */} + {selectedPin.kind === "pin" && } {ENTRIES.map((entry) => (
)} - {pins.some((pin) => pin.entryId === selectedEntry.id) && ( + {mapPins.some((pin) => pin.entryId === selectedEntry.id) && (
{t.patternsFrom}
- {pins + {mapPins .filter((pin) => pin.entryId === selectedEntry.id) .map((pin) => ( + ))} +
+ + )} +
{t.secPrompt}
{buildPrompt(selectedEntry)}
diff --git a/web/src/lib/atlas/data.ts b/web/src/lib/atlas/data.ts index a4fb1ff..6e6d08f 100644 --- a/web/src/lib/atlas/data.ts +++ b/web/src/lib/atlas/data.ts @@ -22,7 +22,7 @@ export type AtlasFamilyId = | "feedback" | "pde" | "life" | "critical" | "invent"; export type AtlasStatusId = - | "verified" | "active" | "hold" | "unexplored" | "invented"; + | "verified" | "active" | "hold" | "unexplored" | "retired" | "invented"; export type AtlasEntry = { id: string; @@ -50,6 +50,15 @@ export type AtlasEntry = { riskEn?: string; impl?: string; implEn?: string; + /** + * The batch this entry joined the map with (YYYY-MM-DD; a second batch the + * same day appends a letter, e.g. 2026-08-16b — the tags compare + * lexicographically). The original chart carries none. The newest tag + * present is what the map calls "new" — past fifty points, "which ones did + * I just add?" stops being answerable by looking, and a research import + * only makes that worse. + */ + added?: string; /** Prompt subject line (English). */ topic?: string; /** Prompt implementation hints (English). */ @@ -88,8 +97,13 @@ export const STATUSES: Record2 실공간 응축 (엄밀한 응축 상전이).", + knobEn: "hop exponent b in u(n)=1+b/n — b<2 homogeneous flow ↔ b>2 real-space condensation (a rigorous condensation transition)", + topic: "A zero-range process with chipping, drawn as a history waterfall — site masses hopping at rate u(n)=1+b/n, condensing into drifting trunk rivers fed by tributary showers; canonical b ≈ 5, density ≈ 3", + hints: ["integer masses in one array; per step pick random sites, move one unit to a neighbor with probability ∝ 1 + b/n — all integer arithmetic", + "occupancies span orders of magnitude: render log2(1 + n), or the trunks whiteout and the tributaries vanish", + "total coarsening into one eternal trunk is the boredom end — cap site mass and burst-split any site that hits the cap, so rivers keep being born"] }, + + { id: "lenia", nm: "Lenia", nmEn: "Lenia", en: "CONTINUOUS LIFE", f: "life", st: "retired", x: 60, y: 72, tex: "연속 커널 CA — 부드러운 생명 형태가 헤엄치는 장.", texEn: "The continuous-kernel cellular automaton — soft life forms swimming as a field.", knob: "커널 반경 / 성장함수 중심·폭 — 종(species)이 바뀌는 파라미터 공간.", @@ -374,10 +547,11 @@ export const ENTRIES: AtlasEntry[] = [ risk: "'생명체'가 개체로 보이는 순간 사망 — 군집/장 스케일 레짐으로만.", riskEn: "The moment a creature is countable it dies — colony/field-scale regimes only.", topic: "Lenia, the continuous cellular automaton — tuned to colony/field-scale regimes (tissues and blooms, never a single creature)", - hints: ["separable or small kernels to fit the ESP32 convolution budget", + hints: ["the wide radial kernel is the whole cost problem, and a sliding-window box sum solves it: a running sum along each scanline (S += in - out) computes any radius in O(1) per pixel, independent of radius, and three cascaded box passes approximate a Gaussian. Approximate the ring kernel as the difference of two such blurs", + "cheaper cousin worth trying first: multiscale Turing (McCabe) — 2-4 scales of activator/inhibitor built from those same box sums, each site stepping toward whichever scale has the least local variation", "if it reads as one countable creature the pattern is dead — stay at colony/field scale"] }, - { id: "nca", nm: "Neural CA", nmEn: "Neural CA", en: "LEARNED RULES", f: "life", st: "unexplored", x: 52, y: 76, + { id: "nca", nm: "Neural CA", nmEn: "Neural CA", en: "LEARNED RULES", f: "life", st: "retired", x: 52, y: 76, tex: "학습된 국소 규칙이 씨앗에서 텍스처를 길러냄 — 규칙 vs 학습의 단층선 위. (연구 수입)", texEn: "A learned local rule growing texture from a seed — sitting on the rules-vs-learning fault line. (research import)", knob: "학습 후엔 급소가 약함 — 노이즈 주입/스텝률 정도. 급소 노브 요건과 긴장 관계.", @@ -387,24 +561,40 @@ export const ENTRIES: AtlasEntry[] = [ topic: "Neural CA texture growth — train the local update rule offline, bake the small weights into the pattern code", hints: ["long shot: training happens outside; inference must fit a tiny net within the per-frame budget"] }, - { id: "fluid", nm: "Stable Fluids 잉크", nmEn: "Stable Fluids ink", en: "REAL FLUID", f: "critical", st: "unexplored", x: 70, y: 86, + { id: "fluid", nm: "Stable Fluids 잉크", nmEn: "Stable Fluids ink", en: "REAL FLUID", f: "critical", st: "retired", x: 70, y: 86, tex: "속도장이 스스로 진화(이류·압력투영)하며 잉크를 실어나름 — 소용돌이의 실제 상호작용.", texEn: "A velocity field evolving itself (advection, pressure projection) while carrying ink — vortices that truly interact.", knob: "점성 / vorticity confinement — 끈적한 흐름 ↔ 격렬한 난류.", knobEn: "viscosity / vorticity confinement — sticky flow ↔ violent turbulence", - topic: "Stable Fluids — a coarse self-evolving velocity field (semi-Lagrangian advection + pressure projection) carrying a full-res ink density", - hints: ["a 32×64 velocity grid carrying 64×128 ink is enough", "a few Jacobi iterations of pressure projection suffice"] }, - - { id: "sandpile", nm: "Abelian 사태", nmEn: "Abelian avalanches", en: "SELF-ORGANIZED CRITICALITY", f: "critical", st: "unexplored", x: 79, y: 52, + risk: "밀도장 semi-Lagrangian 리샘플은 실기 예산 초과 (8월 필드 데이터) — 잉크는 입자로 실어나를 것.", + riskEn: "Semi-Lagrangian density resampling blows the hardware budget (August field data) — carry the ink with particles instead.", + topic: "Stable Fluids — a coarse self-evolving velocity field (semi-Lagrangian advection + pressure projection on the coarse grid only), its ink carried by advected particles depositing at full res", + hints: ["a 32×64 velocity grid is enough; resample only the coarse velocity field, never a full-res density field", + "a few Jacobi iterations of pressure projection suffice", + "carry ink as particles depositing into a fade buffer — the GaleInk route"] }, + + { id: "sandpile", nm: "Abelian 사태", nmEn: "Abelian avalanches", en: "SELF-ORGANIZED CRITICALITY", f: "critical", st: "verified", x: 79, y: 52, tex: "임계 격자의 사태 연쇄 파면 — 멱법칙: 잔반짝임 속 가끔 화면을 삼키는 대붕괴.", texEn: "Cascade fronts of a critical lattice — power law: small flickers, and occasionally a collapse that swallows the screen.", knob: "낙사율/소산 — 아임계 ↔ 자기조직 임계.", knobEn: "drop rate / dissipation — subcritical ↔ self-organized criticality", + impl: "WaveCoupling(2026-08-12 핀) — 위상 연동 문턱 붕괴 변주, 실기 통과.", + implEn: "WaveCoupling (pinned 2026-08-12) — a phase-locked-threshold cascade variant, passed hardware.", topic: "An Abelian sandpile — render the cascading avalanche FRONTS as brightness, never individual grains", hints: ["render avalanche wavefronts with fade; hide the raw lattice values", "power-law sizes: mostly small flickers, occasionally a screen-swallowing collapse"] }, - { id: "dla", nm: "DLA 응집 확률장", nmEn: "DLA probability field", en: "AGGREGATION FIELD", f: "critical", st: "unexplored", x: 52, y: 33, + { id: "forestfire", nm: "산불 임계 순환", nmEn: "Forest-fire criticality", en: "DROSSEL-SCHWABL", f: "critical", st: "unexplored", x: 85, y: 60, added: "2026-08-16", + tex: "자라는 숲, 벼락, 지도를 먹어치우는 화선 — 태우고 다시 자라며 스스로 임계로 돌아오는 모자이크.", + texEn: "A regrowing forest, lightning, fronts that eat the map — a mosaic burning and regrowing its way back to criticality.", + knob: "성장/벼락 비 p/f = 50..2000 — 잔불 반짝임 ↔ 척도 없는 화재 모자이크 ↔ 화면을 삼키는 대화재.", + knobEn: "growth-to-lightning ratio p/f = 50..2000 — small sparks ↔ scale-free fire mosaics ↔ system-spanning burns", + topic: "The Drossel-Schwabl forest-fire model — burning fronts sweeping a regrowing forest, fire scars flowing as dark rivers", + hints: ["states empty/tree/burning in a Uint8Array; per sweep: fire ignites neighboring trees, burning becomes empty, empty regrows with probability p, lightning strikes trees with probability f — all integer", + "render fire bright over dim forest, with an EMA afterglow buffer so scars fade like rivers of ash", + "the timescale separation f << p is what self-organizes criticality; keep both under one ratio knob"] }, + + { id: "dla", nm: "DLA 응집 확률장", nmEn: "DLA probability field", en: "AGGREGATION FIELD", f: "critical", st: "retired", x: 52, y: 33, tex: "확산 응집을 개체 없는 확률장으로 — 서리 가지가 자라는 장.", texEn: "Diffusive aggregation as an object-free probability field — frost branches growing as a field.", knob: "부착 확률 / DBM η — 성긴 가지 ↔ 조밀 덩어리 (연속 조절).", @@ -514,7 +704,7 @@ export const ENTRIES: AtlasEntry[] = [ "render u as the soft body plus |du/dx| (normalized) as bright crease seams", "shock mergers are the narrative — keep forcing gentle so mergers stay readable"] }, - { id: "chladni", nm: "Chladni 고유모드 모핑", nmEn: "Chladni eigenmode morphing", en: "STANDING WAVE MODES", f: "pde", st: "unexplored", x: 22, y: 48, + { id: "chladni", nm: "Chladni 고유모드 모핑", nmEn: "Chladni eigenmode morphing", en: "STANDING WAVE MODES", f: "pde", st: "retired", x: 22, y: 48, tex: "판 진동의 고유모드들 사이를 연속 보간 — 마디선 그물이 접히고 재배열된다. 재현이 아니라 추상 정상파의 기하.", texEn: "Continuous interpolation between plate-vibration eigenmodes — the web of nodal lines folds and rearranges. Not a depiction: the geometry of abstract standing waves.", knob: "모드 지수쌍 (m,n)의 사다리 위치 — 단계마다 마디선 위상이 재배열되는 전이.", @@ -526,11 +716,13 @@ export const ENTRIES: AtlasEntry[] = [ "render v from 1 - |field|, sharpened around the zero crossings, so the nodal lines glow", "grade the mode index along y for a vertical ladder of complexity"] }, - { id: "invasion", nm: "침투 퍼콜레이션", nmEn: "Invasion percolation", en: "WEAKEST-PATH INVASION", f: "critical", st: "unexplored", x: 55, y: 47, + { id: "invasion", nm: "침투 퍼콜레이션", nmEn: "Invasion percolation", en: "WEAKEST-PATH INVASION", f: "critical", st: "verified", x: 55, y: 47, tex: "동결 무질서 격자에서 항상 가장 약한 이웃만 뚫고 번지는 침투 — 확산 없는 프랙탈 손가락, 임계가 내장된 성장. (연구 수입)", texEn: "An invasion that always breaks the weakest neighboring site of a frozen disorder field — fractal fingers without diffusion; growth with criticality built in. (research import)", knob: "무질서 상관길이 / 트래핑 규칙 — 가는 프랙탈 손가락 ↔ 뭉툭한 압축 전선.", knobEn: "disorder correlation length / trapping rule — thin fractal fingers ↔ blunt compact fronts", + impl: "FantasiaGarden(2026-08-11 핀) — 저항 뱅크 스크롤 + 침투 나이 렌더, 실기 통과.", + implEn: "FantasiaGarden (pinned 2026-08-11) — scrolling resistance bank + invasion-age render, passed hardware.", topic: "Invasion percolation — a front that always advances through the weakest site of a quenched random resistance field, rendered as an object-free invasion-age field", hints: ["keep a frontier set; each step invade the minimum-resistance frontier site (a small heap, or a periodic min-scan, is fine at this scale)", "render invasion AGE as tone — the freshly invaded glows, old territory fades; never individual sites", @@ -559,6 +751,8 @@ export const ENTRIES: AtlasEntry[] = [ { id: "fpu", nm: "FPU — 유령 회귀", nmEn: "FPU — ghost recurrence", en: "FERMI-PASTA-ULAM", f: "invent", st: "invented", x: 33, y: 28, tex: "비선형 사슬에 부은 에너지가 모드들로 흩어졌다가, 오랜 방황 끝에 유령처럼 처음 형태로 되돌아온다 — 흩어짐과 회귀의 긴 호흡이 폭포로 흐른다.", texEn: "Energy poured into a nonlinear chain scatters across its modes — then, after a long wander, returns like a ghost to its original shape. The long breath of dispersal and recurrence, flowing as a waterfall.", + risk: "외부 서베이 판정(8/16): 변위 폭포는 매끈한 전역 정상파 — 국소 구조가 없어 지루함. 모드 에너지 밴드 렌더로만 승산이 있다.", + riskEn: "External survey verdict (8/16): the displacement waterfall is smooth global standing waves — no localized structures, monotonous. Only the mode-energy-band render has a chance.", knob: "비선형 강도 β — 완전 회귀 ↔ 준회귀 ↔ 열화(에르고딕)의 문턱.", knobEn: "nonlinearity beta — clean recurrence ↔ partial recurrence ↔ the thermalization threshold", topic: "Fermi-Pasta-Ulam-Tsingou recurrence as a vertical history waterfall — a nonlinear oscillator chain seeded in its lowest mode, energy spreading through the spectrum and ghost-returning; render the displacement field, or the per-mode energies as vertical bands", @@ -576,11 +770,13 @@ export const ENTRIES: AtlasEntry[] = [ "let the hidden signal itself morph slowly (large smooth abstract blobs) so the surfacing picture is alive", "bonus curtain: sweep the noise amplitude along y so a resonant band glows mid-frame"] }, - { id: "choke", nm: "Choke — 배타 수송 충격파", nmEn: "Choke — exclusion shockwaves", en: "ASEP BOUNDARY PHASES", f: "invent", st: "invented", x: 78, y: 24, + { id: "choke", nm: "Choke — 배타 수송 충격파", nmEn: "Choke — exclusion shockwaves", en: "ASEP BOUNDARY PHASES", f: "invent", st: "verified", x: 78, y: 24, tex: "한 방향으로만 흐르는 배타 입자들의 밀도장 — 정체 충격파가 흐름을 거슬러 기어오르고 희박파가 부채꼴로 펴진다. 입자는 안 보이고 밀도의 지층만 흐른다.", texEn: "The density field of one-way excluding particles — jam shockwaves crawling upstream, rarefaction fans spreading. No particles visible, only strata of density flowing.", knob: "주입/배출률 — 저밀도 / 고밀도 / 최대류 상을 가르는 실제 경계 상전이 (1차 전이 포함).", knobEn: "injection/extraction rates — the boundary-driven phase diagram (low-density / high-density / maximal-current, with a genuine first-order line)", + impl: "DefectCascade(2026-08-14 핀) — 발명 대륙 첫 검증. 1D 동역학 + 이력 스크롤 = 실기 최저비용 골격의 증명.", + implEn: "DefectCascade (pinned 2026-08-14) — the invented continent's first verification. 1D dynamics + history scroll, proof of the cheapest hardware architecture.", topic: "A boundary-driven exclusion process (ASEP) rendered as a coarse-grained density field flowing down the frame — jam shocks climbing against the flow, rarefaction fans, and boundary-rate phase transitions", hints: ["simulate a 1D ASEP with random sequential updates per row-time and render coarse-grained density as a history waterfall — never individual particles", "alpha (inject) and beta (extract) span the phase diagram: alpha0.5 maximal current", @@ -652,6 +848,7 @@ Rendering craft — most attempts die here, read carefully: - Simulate in the display's own orientation. Internal buffers are 64 wide × 128 tall (index = y * 64 + x), matching // @matrix 64x128. Do NOT build a 128×64 landscape simulation and rotate or remap it inside draw() — no axis swaps, no dispX = y tricks. If you catch yourself writing const w = 128, h = 64, stop: swap them. (Most LED-matrix code online is landscape; this device stands tall.) - Choose @knobs ranges so the pattern is at its best near the MIDDLE of every range, with nobody touching anything. Knob extremes may be calm or violent; the default position is the show. - Simulations must sit in their interesting regime at those defaults — use the canonical parameter values from the hints below when given; do not invent your own. +- This code is also compiled for a 240 MHz microcontroller, where sin/cos/exp/pow/atan2 each cost hundreds of cycles. Budget transcendentals: keep them OUT of the per-pixel loop — per-agent, per-row, per-timestep math is fine, expensive fields can be computed on a coarse control grid (a few hundred nodes) and interpolated up, and integer/add/multiply lattice rules are free. At most one full-resolution pass per frame, carrying no more than a couple of trig calls per pixel. Never resample the previous frame per pixel (no warp/zoom/bilinear feedback), never do O(n²) all-pairs interactions, and use Float32Array only — never Float64Array (doubles are software-emulated). Taste direction (settled by experiment on this device — treat as hard constraints, on top of everything above): - No countable objects. Thousands of accumulated operations must read as one continuous "material". The moment dots/creatures/cars can be counted, the pattern is dead. @@ -659,7 +856,7 @@ Taste direction (settled by experiment on this device — treat as hard constrai - Refining "creative control mapping" above: Knob 1 must grip a real coefficient of the underlying equation (the critical knob) — turning it must cross a phase transition or bifurcation, not just restyle. Knob 3 = density/scale, knob 4 = fade/persistence (wire ↔ smoke) have worked well. - Morphing: let secondary coefficients breathe slowly on incommensurate periods so that five minutes in it is not the same picture. The autonomous morphing must NOT ride the critical coefficient itself — pumping it injects energy and can blow the system up; morph through harmless axes (time compression, render transforms). - The long vertical axis is the protagonist: falling, rising, columns, history scrolling downward. -- Reliability: clamp dt (~0.1 max), detect divergence and auto-reseed, use a seeded RNG instead of Math.random, allocate every buffer in setup, attach helper functions to params as closures (survives layer flattening), and stay ESP32-friendly (tens of thousands of operations per frame). +- Reliability: clamp dt (~0.1 max), detect divergence and auto-reseed, use a seeded RNG instead of Math.random, allocate every buffer in setup, attach helper functions to params as closures (survives layer flattening), and stay inside the microcontroller budget stated under Rendering craft. - Each pattern must declare in its first comment: "critical knob = ___, turning it crosses ___ ↔ ___". - Discard any idea that fails these rules — output only survivors. If you cannot execute and test the code, mark the pattern "UNVERIFIED".`; @@ -678,3 +875,20 @@ export function buildPrompt(entry: AtlasEntry): string { export const ENTRY_BY_ID: ReadonlyMap = new Map( ENTRIES.map((entry) => [entry.id, entry]), ); + +/** + * The latest batch tag on the map — what the "new" filter shows. + * + * Derived rather than declared: tag an import with today's date and it becomes + * the new arrivals while the previous batch ages out on its own. Nothing to + * remember to switch off. + */ +export const NEWEST_BATCH: string | null = ENTRIES.reduce( + (newest, entry) => + entry.added && (newest === null || entry.added > newest) ? entry.added : newest, + null, +); + +export function isNewEntry(entry: AtlasEntry): boolean { + return NEWEST_BATCH !== null && entry.added === NEWEST_BATCH; +} diff --git a/web/src/lib/community/queries.ts b/web/src/lib/community/queries.ts index aa24c13..0af820e 100644 --- a/web/src/lib/community/queries.ts +++ b/web/src/lib/community/queries.ts @@ -644,16 +644,21 @@ export async function listPresence(): Promise { /** * Patterns placed on the atlas (/community/atlas), with enough of the pattern - * row to render a live tile. Only public patterns appear — an unlisted work is - * link-only everywhere else, and a spot on the shared map would un-unlist it. + * row to render a live tile. Map pins ("pin") are public patterns only — an + * unlisted work is link-only everywhere else, and a spot on the shared map + * would un-unlist it. Research rows ("research") may be private: everyone sees + * the public ones, but a private failure is shown only to its author (or a + * moderator) — pass the viewer so the filter can tell. */ -export async function listAtlasPins() { - return getDb() +export async function listAtlasPins(viewer?: { id: string; isAdmin: boolean } | null) { + const rows = await getDb() .select({ patternId: atlasPins.patternId, x: atlasPins.x, y: atlasPins.y, entryId: atlasPins.entryId, + kind: atlasPins.kind, + visibility: patterns.visibility, title: patterns.title, code: patterns.code, userId: patterns.userId, @@ -662,8 +667,12 @@ export async function listAtlasPins() { .from(atlasPins) .innerJoin(patterns, eq(atlasPins.patternId, patterns.id)) .innerJoin(user, eq(patterns.userId, user.id)) - .where(eq(patterns.visibility, "public")) .orderBy(atlasPins.updatedAt); + return rows.filter((row) => { + if (row.visibility === "public") return true; + if (row.kind !== "research") return false; // a map pin never carries a non-public pattern + return Boolean(viewer && (viewer.isAdmin || viewer.id === row.userId)); + }); } /** diff --git a/web/src/lib/community/schema.ts b/web/src/lib/community/schema.ts index edd48d2..55b1d77 100644 --- a/web/src/lib/community/schema.ts +++ b/web/src/lib/community/schema.ts @@ -479,6 +479,14 @@ export const atlasPins = sqliteTable("atlas_pins", { * panel can retarget it. Null = placed in open water, tied to nothing. */ entryId: text("entry_id"), + /** + * "pin" = an exemplar tile on the shared map. "research" = a field note: an + * attempt filed against an entry, kept as data but not drawn as a tile — + * failures are worth remembering exactly where they happened. Research rows + * may reference private patterns (a failure often is); the read path only + * shows those to their author and moderators, so nothing private leaks. + */ + kind: text("kind").notNull().default("pin"), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), });