WikiPuff base44



---
### `src/lib/worldMap.js`
```js
// World-map geometry + helpers for the Google-Earth-style knowledge atlas.
// A large, detailed world with 8 domain-continents, biomes, rivers, mountains,
// forests, roads, and procedural decorative detail.
export const WORLD = { width: 8000, height: 6000 };
export const DOMAINS = [
{ key: "science", name: "Natural Sciences", color: "#06B6D4", landColor: "#DCEFB8", center: { x: 1700, y: 1200 }, radius: 1150, subRegions: ["Physics Peaks", "Chemistry Marsh", "Biology Vale", "Math Ridge"] },
{ key: "space", name: "Space & Cosmos", color: "#6366F1", landColor: "#E6E0F5", center: { x: 5600, y: 1100 }, radius: 1100, subRegions: ["Star Fields", "Galaxy Reach", "Planet Belt", "Cosmic Deep"] },
{ key: "life", name: "Life & Nature", color: "#22C55E", landColor: "#D6EBBE", center: { x: 1500, y: 3000 }, radius: 1200, subRegions: ["Forest of Fauna", "Coral Shallows", "Tundra Edge", "Savanna Plains"] },
{ key: "tech", name: "Technology & Engineering", color: "#F59E0B", landColor: "#F3E2C0", center: { x: 6100, y: 2900 }, radius: 1150, subRegions: ["Silicon Valley", "Engine Works", "Code Labs", "Network Hub"] },
{ key: "arts", name: "Arts & Humanities", color: "#F43F5E", landColor: "#F5D9D9", center: { x: 3300, y: 2000 }, radius: 1050, subRegions: ["Muse Quarter", "Story Heights", "Sound Atrium", "Stage Court"] },
{ key: "history", name: "History & Culture", color: "#8B5CF6", landColor: "#E8DCC6", center: { x: 3300, y: 4600 }, radius: 1150, subRegions: ["Empire Ruins", "Dynasty Row", "Battle Plains", "Treaty Hall"] },
{ key: "geo", name: "Geography & Places", color: "#0EA5E9", landColor: "#D7E9E0", center: { x: 1700, y: 5000 }, radius: 1050, subRegions: ["Mountain Spine", "River Delta", "Desert Expanse", "Coast Range"] },
{ key: "food", name: "Food & Culture", color: "#FB923C", landColor: "#F3DDC4", center: { x: 6100, y: 4900 }, radius: 1050, subRegions: ["Spice Bazaar", "Harvest Fields", "Kitchen Quarter", "Cellar Vaults"] },
];
const KEYWORDS = {
space: ["galaxy","planet","star","moon","sun","solar","universe","black hole","comet","asteroid","nebula","mars","jupiter","saturn","venus","mercury","neptune","uranus","pluto","eclipse","meteor","constellation","cosmos","astronaut","rocket","orbit","supernova","quasar","telescope","space","big bang","dark matter","exoplanet","gravity","wormhole","pulsar","milky way"],
life: ["lion","tiger","bear","wolf","fox","eagle","whale","dolphin","elephant","penguin","panda","coral","bee","butterfly","tree","forest","ocean","river","mountain","volcano","glacier","desert","rainforest","reef","shark","octopus","jellyfish","dinosaur","bird","insect","plant","flower","mushroom","fungi","moss","ecosystem","habitat","animal","snake","frog","turtle","spider","ant","jungle","tundra","savanna","wetland","mangrove","kelp","plankton","evolution","species","biodiversity","cell","dna","gene","bacteria","virus","ecology","climate","weather","hurricane","tornado","earthquake","tsunami","geyser","aurora","fjord","canyon","waterfall","cave","island","archipelago","iceberg","monsoon","wildfire","symbiosis","migration","fossil","reef","forest","coral","polar bear","arctic","antarctic","prairie","meadow","marsh","bog","delta","estuary","oasis","dune","mesa","plateau","valley","basin","gorge","peninsula","strait","gulf","bay","lagoon","atoll"],
science: ["atom","molecule","quantum","photon","electron","proton","neutron","light","electricity","magnet","chemistry","physics","element","periodic","acid","reaction","crystal","mineral","energy","thermodynamic","relativity","particle","wave","frequency","radiation","friction","inertia","force","velocity","entropy","catalyst","polymer","isotope","fission","fusion","superconductor","semiconductor","magnetism","optics","prism","spectrum","laser","nanotech","graphene","plasma","quark","boson","fermion","entanglement","superposition","tunneling","diffraction","spectroscopy","microscope","accelerator","reactor"],
tech: ["computer","internet","ai","robot","engine","machine","code","software","algorithm","network","data","chip","semiconductor","transistor","battery","solar panel","wind turbine","bridge","skyscraper","car","airplane","phone","satellite","drone","circuit","processor","memory","server","cloud","cyber","encryption","blockchain","crypto","virtual reality","robotics","automation","hydraulic","turbine","generator","motor","transformer","capacitor","sensor","antenna","radar","lidar","gps","autopilot","navigation"],
arts: ["painting","sculpture","music","dance","theater","literature","poetry","novel","film","cinema","photography","architecture","design","fashion","language","philosophy","religion","myth","legend","story","art","renaissance","baroque","impressionism","symphony","opera","ballet","jazz","rock","classical","portrait","landscape","mural","fresco","mosaic","calligraphy","typography","surrealism","cubism","expressionism","abstract","pop art","manga","anime","comics","animation","musical","sonata","fugue","orchestra","piano","violin","guitar","poem","sonnet","haiku","epic"],
history: ["empire","war","revolution","king","queen","civilization","ancient","egypt","rome","greece","medieval","dynasty","battle","treaty","colony","independence","constitution","democracy","republic","pharaoh","pyramid","colosseum","parthenon","stonehenge","crusade","reformation","industrial","colonial","feudal","viking","knight","samurai","gladiator","legion","castle","fortress","siege","conquest","emperor","tsar","sultan","caliph","shogun"],
geo: ["country","city","continent","island","sea","lake","capital","map","compass","latitude","longitude","equator","pole","tundra","savanna","jungle","desert","mountain","river","valley","canyon","plateau","delta","estuary","oasis","volcano","glacier","fjord","isthmus","peninsula","strait","gulf","bay","lagoon","atoll","archipelago","cape","mesa","butte","dune","gorge","cliff","basin","ridge","reef","trench","rift","fault","plain","steppe","pampas","sahara","gobi","namib","atacama"],
food: ["coffee","tea","chocolate","bread","wine","cheese","sushi","pizza","curry","spice","honey","salt","sugar","rice","corn","potato","tomato","noodle","pasta","yogurt","butter","olive","vinegar","sauce","soup","stew","roast","grill","bake","ferment","pickle","jam","candy","caramel","baklava","tiramisu","gelato","croissant","bagel","pretzel","donut","naan","tortilla","sourdough","miso","tofu","sake","whisky","rum"],
};
export function hashStr(s = "") {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function classifyDomain(title = "") {
const t = (title || "").toLowerCase();
for (const d of DOMAINS) {
const kws = KEYWORDS[d.key] || [];
if (kws.some((k) => t.includes(k))) return d.key;
}
return DOMAINS[hashStr(title) % DOMAINS.length].key;
}
export function blobPath(cx, cy, r, seed, n = 24) {
const rng = mulberry32(seed);
const pts = [];
for (let i = 0; i < n; i++) {
const ang = (i / n) Math.PI 2;
const rr = r (0.72 + rng() 0.42);
pts.push([cx + Math.cos(ang) rr, cy + Math.sin(ang) rr]);
}
const mid = [];
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
mid.push([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
}
let d = `M ${mid[0][0].toFixed(1)} ${mid[0][1].toFixed(1)}`;
for (let i = 0; i < n; i++) {
const c = pts[i];
const m = mid[(i + 1) % n];
d += ` Q ${c[0].toFixed(1)} ${c[1].toFixed(1)} ${m[0].toFixed(1)} ${m[1].toFixed(1)}`;
}
return d + " Z";
}
export function worldToLatLon(x, y) {
const lat = 90 - (y / WORLD.height) * 180;
const lon = (x / WORLD.width) * 360 - 180;
return { lat, lon };
}
export function getNodePositions(nodes = []) {
const groups = {};
for (const n of nodes) {
const key = classifyDomain(n.title);
(groups[key] = groups[key] || []).push(n);
}
const out = new Map();
for (const d of DOMAINS) {
const list = (groups[d.key] || []).slice().sort((a, b) => a.title.localeCompare(b.title));
const count = list.length;
list.forEach((node, i) => {
const ang = i * 2.39996323;
const r = Math.sqrt((i + 1) / Math.max(1, count)) (d.radius 0.82);
out.set(node.id, {
x: d.center.x + Math.cos(ang) r 1.15,
y: d.center.y + Math.sin(ang) * r,
domain: d.key,
domainColor: d.color,
domainName: d.name,
});
});
}
return out;
}
// A wavy river crossing a continent.
export function riverPath(seed, cx, cy, r) {
const rng = mulberry32(seed);
const startX = cx - r * 0.8;
const endX = cx + r * 0.8;
const segs = 6;
let d = "";
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const x = startX + (endX - startX) * t;
const y = cy + Math.sin(t Math.PI 2 + rng() 3) r 0.22 + (rng() - 0.5) r * 0.1;
d += i === 0 ? `M ${x.toFixed(1)} ${y.toFixed(1)}` : ` Q ${(x - 30).toFixed(1)} ${(y - 20).toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)}`;
}
return d;
}
// Mountain peaks along an arc.
export function mountainPeaks(seed, cx, cy, r, count = 7) {
const rng = mulberry32(seed);
const out = [];
for (let i = 0; i < count; i++) {
const t = i / (count - 1);
const x = cx - r 0.7 + r 1.4 * t;
const y = cy - r 0.3 + Math.sin(t Math.PI) r 0.1 + (rng() - 0.5) r 0.08;
const h = 70 + rng() * 90;
out.push({ x, y, h });
}
return out;
}
// Forest cluster dot positions.
export function forestDots(seed, cx, cy, r, count = 14) {
const rng = mulberry32(seed);
const out = [];
for (let i = 0; i < count; i++) {
const ang = rng() Math.PI 2;
const rr = rng() r 0.6;
out.push({ x: cx + Math.cos(ang) rr, y: cy + Math.sin(ang) rr });
}
return out;
}
// Connect each node to its 2 nearest neighbors within the same domain.
export function roadSegments(pointList) {
const byDomain = {};
for (const p of pointList) (byDomain[p.domain] = byDomain[p.domain] || []).push(p);
const segs = [];
const seen = new Set();
for (const key of Object.keys(byDomain)) {
const pts = byDomain[key];
pts.forEach((p) => {
const neighbors = pts
.map((q) => ({ q, d: Math.hypot(q.x - p.x, q.y - p.y) }))
.sort((a, b) => a.d - b.d)
.slice(0, 2);
for (const { q } of neighbors) {
if (seen.has(id)) continue;
seen.add(id);
segs.push({ x1: p.x, y1: p.y, x2: q.x, y2: q.y, domain: key });
}
});
}
return segs;
}
```
---
### `src/lib/explorerProgress.js`
```js
// Local gamification engine for the explorer atlas. Persists per-browser in
// localStorage. Tracks XP/level/status, stats, achievements, and a time-boxed
// "expedition" mission that rotates toward the explorer's interests.
import { DOMAINS } from "./worldMap";
const KEY = "wikiPuff_explorer_v1";
export const LEVELS = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700, 3400, 4200, 5200];
export const STATUSES = ["Wanderer", "Seeker", "Cartographer", "Trailblazer", "Scholar", "Sage", "Polymath", "Luminary", "Astral", "Omniscient", "Ascendant", "Singularity", "Beyond"];
export const ACHIEVEMENTS = [
{ id: "landfall", title: "First Landfall", desc: "Visit your first place", xp: 20, test: (p) => visitedCount(p) >= 1 },
{ id: "cartographer", title: "Cartographer", desc: "Visit 10 places", xp: 40, test: (p) => visitedCount(p) >= 10 },
{ id: "voyager", title: "Voyager", desc: "Visit 25 places", xp: 80, test: (p) => visitedCount(p) >= 25 },
{ id: "pathfinder", title: "Pathfinder", desc: "Visit 50 places", xp: 150, test: (p) => visitedCount(p) >= 50 },
{ id: "continental", title: "Continental", desc: "Set foot on all 8 continents", xp: 120, test: (p) => Object.keys(p.domainsVisited || {}).length >= 8 },
{ id: "deepdiver", title: "Deep Diver", desc: "Zoom to depth 2.0×", xp: 60, test: (p) => p.maxDepth >= 2.0 },
{ id: "abyssal", title: "Abyssal", desc: "Zoom to depth 3.0×", xp: 100, test: (p) => p.maxDepth >= 3.0 },
{ id: "archivist", title: "Archivist", desc: "Open 50 layers", xp: 80, test: (p) => p.pinsOpened >= 50 },
{ id: "curator", title: "Curator", desc: "Open 150 layers", xp: 150, test: (p) => p.pinsOpened >= 150 },
{ id: "researcher", title: "Researcher", desc: "Run 3 deep dives", xp: 90, test: (p) => p.deepDives >= 3 },
{ id: "polymath", title: "Polymath", desc: "Run 10 deep dives", xp: 150, test: (p) => p.deepDives >= 10 },
{ id: "conversationalist", title: "Conversationalist", desc: "Read 20 threads", xp: 50, test: (p) => p.commentsRead >= 20 },
{ id: "expeditionary", title: "Expeditionary", desc: "Complete 5 expeditions", xp: 120, test: (p) => (p.expeditionsCompleted || 0) >= 5 },
];
export function visitedCount(p) {
return Object.keys(p.visited || {}).length;
}
export function levelFromXp(xp) {
let level = 1;
for (let i = 0; i < LEVELS.length; i++) {
if (xp >= LEVELS[i]) level = i + 1;
}
const idx = Math.min(level - 1, STATUSES.length - 1);
const cur = LEVELS[Math.min(level - 1, LEVELS.length - 1)];
const next = LEVELS[Math.min(level, LEVELS.length - 1)];
const pct = next > cur ? Math.min(1, (xp - cur) / (next - cur)) : 1;
return { level, status: STATUSES[idx], cur, next, pct, isMax: level >= LEVELS.length };
}
export function defaultProgress() {
return {
xp: 0,
visited: {},
domainsVisited: {},
pinsOpened: 0,
commentsRead: 0,
maxDepth: 1,
deepDives: 0,
expeditionsCompleted: 0,
achievements: [],
events: [],
mission: null,
sessionStart: Date.now(),
};
}
export function load() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return defaultProgress();
const p = { ...defaultProgress(), ...JSON.parse(raw) };
return p;
} catch {
return defaultProgress();
}
}
export function save(p) {
try {
localStorage.setItem(KEY, JSON.stringify(p));
} catch {}
return p;
}
function pushEvent(p, ev) {
}
function topInterestDomain(p, exclude) {
const counts = { ...(p.domainsVisited || {}) };
let best = null;
let bestN = -1;
for (const d of DOMAINS) {
if (d.key === exclude) continue;
const n = counts[d.key] || 0;
if (n > bestN) {
bestN = n;
best = d.key;
}
}
if (best && bestN > 0) return best;
// nothing visited yet: pick a random domain
return DOMAINS[Math.floor(Math.random() * DOMAINS.length)].key;
}
export function generateMission(p) {
const { level } = levelFromXp(p.xp);
const domain = topInterestDomain(p, p.mission?.domain);
const d = DOMAINS.find((x) => x.key === domain);
return {
domain,
domainName: d?.name || domain,
domainColor: d?.color || "#06B6D4",
target: Math.min(8, 3 + Math.floor(level / 2)),
depth: Math.min(2.6, 1 + level * 0.18),
timeframeS: 150 + level * 20,
startedAt: Date.now(),
};
}
export function missionProgress(p, now = Date.now()) {
const m = p.mission;
if (!m) return { progress: 0, remaining: 0, status: "none" };
const qualifying = (p.events || []).filter(
(e) => e.type === "visit" && e.domain === m.domain && (e.depth || 1) >= m.depth && e.t >= m.startedAt
);
const progress = qualifying.length;
const elapsed = now - m.startedAt;
const remaining = Math.max(0, m.timeframeS * 1000 - elapsed);
if (remaining <= 0) return { progress, target: m.target, remaining: 0, status: "expired" };
return { progress, target: m.target, remaining, status: "active" };
}
// Returns { progress, unlocked: [], missionCompleted, missionExpired }
export function recordVisit(p, { nodeId, domain, depth }) {
const now = Date.now();
p.visited = p.visited || {};
p.domainsVisited = p.domainsVisited || {};
const first = !p.visited[nodeId];
p.visited[nodeId] = true;
p.domainsVisited[domain] = (p.domainsVisited[domain] || 0) + 1;
pushEvent(p, { type: "visit", nodeId, domain, depth });
if (first) addXp(p, 10);
return tick(p, now, depth);
}
export function recordPin(p) {
p.pinsOpened = (p.pinsOpened || 0) + 1;
addXp(p, 5);
return tick(p, Date.now(), p.maxDepth);
}
export function recordComment(p) {
p.commentsRead = (p.commentsRead || 0) + 1;
addXp(p, 2);
return tick(p, Date.now(), p.maxDepth);
}
export function recordDepth(p, scale) {
if (scale > (p.maxDepth || 1)) {
p.maxDepth = scale;
addXp(p, 15);
return tick(p, Date.now(), scale);
}
return tick(p, Date.now(), p.maxDepth);
}
export function recordDeepDive(p) {
p.deepDives = (p.deepDives || 0) + 1;
addXp(p, 25);
return tick(p, Date.now(), p.maxDepth);
}
export function addXp(p, n) {
p.xp = (p.xp || 0) + n;
}
// Evaluate achievements + mission. Returns summary object.
function tick(p, now, depth) {
const unlocked = [];
for (const a of ACHIEVEMENTS) {
if (!(p.achievements || []).includes(a.id) && a.test(p)) {
p.achievements = [...(p.achievements || []), a.id];
addXp(p, a.xp);
unlocked.push(a);
}
}
let missionCompleted = false;
let missionExpired = false;
if (!p.mission) {
p.mission = generateMission(p);
} else {
const st = missionProgress(p, now);
if (st.status === "complete") {
missionCompleted = true;
p.expeditionsCompleted = (p.expeditionsCompleted || 0) + 1;
addXp(p, 50 + (p.mission.target || 3) * 10);
p.mission = generateMission(p);
} else if (st.status === "expired") {
missionExpired = true;
p.mission = generateMission(p);
}
}
return { unlocked, missionCompleted, missionExpired, depth };
}
```
---
### `src/components/wiki/WorldMap.jsx`
```jsx
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { WORLD, getNodePositions, roadSegments } from "@/lib/worldMap";
import WorldTerrain from "./WorldTerrain";
import WorldFog from "./WorldFog";
import WorldMinimap from "./WorldMinimap";
import WorldChrome from "./WorldChrome";
import WorldNodeMarker from "./WorldNodeMarker";
import PinCard from "./PinCard";
const clampZoom = (z) => Math.min(3, Math.max(0.12, z));
const CLUSTER = 520;
// Large explorable world: pan with inertia, zoom toward cursor, fly-to nodes,
// fog of war that reveals as you explore, roads linking places, and hooks for
// gamified tracking + deep research.
export default function WorldMap({
nodes,
activeNodeId,
pins,
activePinId,
onSelectNode,
onSelectPin,
onStageClick,
visitedIds,
onVisit,
onOpenPin,
onZoomChange,
}) {
const containerRef = useRef(null);
const [viewport, setViewport] = useState({ w: 800, h: 600 });
const [scale, setScale] = useState(0.16);
const [focus, setFocus] = useState({ x: WORLD.width / 2, y: WORLD.height / 2 });
const scaleRef = useRef(scale);
const drag = useRef(null);
const anim = useRef(null);
const inertia = useRef({ raf: null });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setViewport({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
scaleRef.current = scale;
}, [scale]);
const nodePoints = useMemo(() => getNodePositions(nodes), [nodes]);
const pointList = useMemo(
() => Array.from(nodePoints.entries()).map(([id, p]) => ({ id, ...p })),
[nodePoints]
);
const roads = useMemo(() => roadSegments(pointList), [pointList]);
const visitedSet = useMemo(() => new Set(visitedIds || []), [visitedIds]);
const exploredPoints = useMemo(() => pointList.filter((p) => visitedSet.has(p.id)), [pointList, visitedSet]);
const stopInertia = useCallback(() => {
if (inertia.current.raf) {
cancelAnimationFrame(inertia.current.raf);
inertia.current.raf = null;
}
}, []);
const flyTo = useCallback(
(tx, ty, ts) => {
if (anim.current) cancelAnimationFrame(anim.current);
const start = { x: focus.x, y: focus.y, s: scale };
const t0 = performance.now();
const dur = 900;
const ease = (t) => (t < 0.5 ? 4 t t t : 1 - Math.pow(-2 t + 2, 3) / 2);
const step = (now) => {
const t = Math.min(1, (now - t0) / dur);
const e = ease(t);
setFocus({ x: start.x + (tx - start.x) e, y: start.y + (ty - start.y) e });
setScale(start.s + (ts - start.s) * e);
if (t < 1) anim.current = requestAnimationFrame(step);
else anim.current = null;
};
anim.current = requestAnimationFrame(step);
},
[focus, scale]
);
useEffect(() => {
if (!activeNodeId) return;
const p = nodePoints.get(activeNodeId);
if (!p) return;
flyTo(p.x, p.y, 1.1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNodeId, nodePoints]);
// wheel zoom toward cursor
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onWheel = (e) => {
e.preventDefault();
if (anim.current) { cancelAnimationFrame(anim.current); anim.current = null; }
stopInertia();
const rect = el.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const s = scaleRef.current;
const ns = clampZoom(s * (e.deltaY < 0 ? 1.12 : 0.89));
setFocus((f) => {
const wx = f.x + (cx - viewport.w / 2) / s;
const wy = f.y + (cy - viewport.h / 2) / s;
return { x: wx - (cx - viewport.w / 2) / ns, y: wy - (cy - viewport.h / 2) / ns };
});
setScale(ns);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [viewport, stopInertia]);
// report zoom changes (debounced) for depth tracking
useEffect(() => {
const t = setTimeout(() => onZoomChange?.(scale), 350);
return () => clearTimeout(t);
}, [scale, onZoomChange]);
const onPointerDown = (e) => {
if (e.button !== 0 && e.pointerType === "mouse") return;
if (anim.current) { cancelAnimationFrame(anim.current); anim.current = null; }
stopInertia();
drag.current = {
x: e.clientX, y: e.clientY, fx: focus.x, fy: focus.y, moved: false,
t: performance.now(), vx: 0, vy: 0, lfx: focus.x, lfy: focus.y,
};
};
const onPointerMove = (e) => {
if (!drag.current) return;
const dx = e.clientX - drag.current.x;
const dy = e.clientY - drag.current.y;
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true;
const nfx = drag.current.fx - dx / scale;
const nfy = drag.current.fy - dy / scale;
setFocus({ x: nfx, y: nfy });
const now = performance.now();
const dt = Math.max(1, now - drag.current.t);
drag.current.vx = (nfx - drag.current.lfx) / dt;
drag.current.vy = (nfy - drag.current.lfy) / dt;
drag.current.lfx = nfx; drag.current.lfy = nfy; drag.current.t = now;
};
const onPointerUp = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (!d.moved) { onStageClick?.(); return; }
let vx = d.vx || 0, vy = d.vy || 0;
if (Math.hypot(vx, vy) < 0.02) return;
const decay = () => {
vx = 0.92; vy = 0.92;
if (Math.hypot(vx, vy) < 0.005) { inertia.current.raf = null; return; }
setFocus((f) => ({ x: f.x + vx 16, y: f.y + vy 16 }));
inertia.current.raf = requestAnimationFrame(decay);
};
inertia.current.raf = requestAnimationFrame(decay);
};
const handleSelectNode = (node) => {
onSelectNode(node);
onVisit?.(node, scaleRef.current);
};
const handleSelectPin = (pin) => {
onSelectPin(pin);
onOpenPin?.(pin);
};
const activeNodePos = activeNodeId ? nodePoints.get(activeNodeId) : null;
const tx = viewport.w / 2 - focus.x * scale;
const ty = viewport.h / 2 - focus.y * scale;
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden select-none cursor-grab active:cursor-grabbing"
style={{ touchAction: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<div
className="absolute left-0 top-0"
style={{
width: WORLD.width,
height: WORLD.height,
transform: `translate(${tx}px, typx)scale({scale})`,
transformOrigin: "0 0",
}}
>
<WorldTerrain />
{/* roads linking places within each continent */}
<svg width={WORLD.width} height={WORLD.height} className="absolute left-0 top-0" style={{ overflow: "visible", pointerEvents: "none" }}>
{roads.map((r, i) => (
<line key={i} x1={r.x1} y1={r.y1} x2={r.x2} y2={r.y2} stroke="#1E293B" strokeOpacity={0.16} strokeWidth={4} strokeDasharray="10 8" strokeLinecap="round" />
))}
</svg>
<WorldFog exploredPoints={exploredPoints} focus={focus} />
{pointList.map((p) => {
if (!node) return null;
return (
<WorldNodeMarker
key={p.id}
node={node}
pos={p}
scale={scale}
active={p.id === activeNodeId}
onSelect={handleSelectNode}
/>
);
})}
{activeNodePos && (
<div
className="absolute"
style={{
left: activeNodePos.x - CLUSTER / 2,
top: activeNodePos.y - CLUSTER / 2,
width: CLUSTER,
height: CLUSTER,
zIndex: 70,
}}
>
{pins.map((pin) => (
))}
</div>
)}
</div>
<WorldMinimap
nodePoints={pointList}
focus={focus}
scale={scale}
viewport={viewport}
onPick={(x, y) => {
stopInertia();
if (anim.current) { cancelAnimationFrame(anim.current); anim.current = null; }
setFocus({ x, y });
}}
/>
<WorldChrome
focus={focus}
scale={scale}
viewport={viewport}
onZoomIn={() => setScale((s) => clampZoom(s * 1.25))}
onZoomOut={() => setScale((s) => clampZoom(s * 0.8))}
onFlyWorld={() => flyTo(WORLD.width / 2, WORLD.height / 2, 0.16)}
/>
<div className="absolute left-1/2 -translate-x-1/2 bottom-4 z-20 text-[10px] font-semibold text-slate-deep/60 pointer-events-none hidden sm:block">
Drag to roam · Scroll to zoom · Click a place to fly in · Use Deep Research to go deeper
</div>
</div>
);
}
```
---
### `src/components/wiki/WorldTerrain.jsx`
```jsx
import React from "react";
import { WORLD, DOMAINS, blobPath, hashStr, riverPath, mountainPeaks, forestDots } from "@/lib/worldMap";
// The painted world: ocean, lat/long grid, organic continents with biomes,
// rivers, mountain ranges, forests, sub-region labels, and domain labels.
export default function WorldTerrain() {
const grid = [];
for (let i = 0; i <= WORLD.width; i += 500) {
grid.push(
<line key={`v${i}`} x1={i} y1={0} x2={i} y2={WORLD.height} stroke="#1E293B" strokeOpacity={0.04} strokeWidth={2} />,
<line key={`h${i}`} x1={0} y1={i} x2={WORLD.width} y2={i} stroke="#1E293B" strokeOpacity={0.04} strokeWidth={2} />
);
}
return (
<svg
width={WORLD.width}
height={WORLD.height}
className="absolute left-0 top-0"
style={{ overflow: "visible", pointerEvents: "none" }}
>
<defs>
<radialGradient id="oceanGrad" cx="50%" cy="42%" r="75%">
<stop offset="0%" stopColor="#C7E7F7" />
<stop offset="55%" stopColor="#A6D6EE" />
<stop offset="100%" stopColor="#7FBEDC" />
</radialGradient>
<radialGradient id="landSheen" cx="48%" cy="38%" r="62%">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.5" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0" />
</radialGradient>
<radialGradient id="coastShade" cx="50%" cy="100%" r="80%">
<stop offset="0%" stopColor="#1E293B" stopOpacity="0" />
<stop offset="100%" stopColor="#1E293B" stopOpacity="0.12" />
</radialGradient>
</defs>
<rect x={0} y={0} width={WORLD.width} height={WORLD.height} fill="url(#oceanGrad)" />
{grid}
{DOMAINS.map((d) => {
const seed = hashStr(d.key);
const forests = forestDots(seed + 9, d.center.x + d.radius 0.2, d.center.y + d.radius 0.25, d.radius, 16);
return (
<g key={d.key}>
<path d={shadow} fill="#1E5A7A" fillOpacity={0.16} />
<path d={land} fill={d.landColor} stroke={d.color} strokeOpacity={0.5} strokeWidth={8} strokeLinejoin="round" />
<path d={land} fill="url(#coastShade)" />
<path d={land} fill="url(#landSheen)" />
{/* river */}
<path d={river} fill="none" stroke="#7FBEDC" strokeWidth={10} strokeLinecap="round" strokeOpacity={0.7} />
<path d={river} fill="none" stroke="#A6D6EE" strokeWidth={4} strokeLinecap="round" />
{/* mountain range */}
{peaks.map((p, i) => (
<g key={i}>
<path d={`M ${p.x - p.h} ${p.y} L ${p.x} ${p.y - p.h * 1.3} L ${p.x + p.h} ${p.y} Z`} fill="#9CA7B5" stroke="#1E293B" strokeOpacity={0.3} strokeWidth={2} strokeLinejoin="round" />
<path d={`M ${p.x - p.h 0.35} ${p.y - p.h 0.45} L ${p.x} ${p.y - p.h 1.3} L ${p.x + p.h 0.35} ${p.y - p.h * 0.45} Z`} fill="#ffffff" fillOpacity={0.7} />
</g>
))}
{/* forests */}
{forests.map((f, i) => (
<g key={i}>
<circle cx={f.x} cy={f.y} r={9} fill={d.color} fillOpacity={0.35} stroke="#1E293B" strokeOpacity={0.15} strokeWidth={1.5} />
<path d={`M ${f.x} ${f.y - 8} L ${f.x - 6} ${f.y + 4} L ${f.x + 6} ${f.y + 4} Z`} fill={d.color} fillOpacity={0.5} />
</g>
))}
{/* sub-region labels */}
{d.subRegions.map((name, i) => {
const ang = (i / d.subRegions.length) Math.PI 2 + 0.4;
const sx = d.center.x + Math.cos(ang) d.radius 0.55;
const sy = d.center.y + Math.sin(ang) d.radius 0.5;
return (
<text key={i} x={sx} y={sy} textAnchor="middle" fontSize={30} fontWeight={700} fill="#1E293B" fillOpacity={0.28} style={{ fontFamily: "Fredoka, sans-serif" }}>
{name}
</text>
);
})}
{/* domain label */}
<text x={d.center.x} y={d.center.y - d.radius * 0.62} textAnchor="middle" fontSize={56} fontWeight={800} fill={d.color} fillOpacity={0.45} style={{ letterSpacing: 3, fontFamily: "Fredoka, sans-serif" }}>
{d.name}
</text>
</g>
);
})}
</svg>
);
}
```
---
### `src/components/wiki/WorldFog.jsx`
```jsx
import React from "react";
import { WORLD } from "@/lib/worldMap";
// Fog of war: dark overlay over the world with soft clearings at every
// explored place and around the current view — so the world reveals and
// deepens in the direction the explorer is heading.
export default function WorldFog({ exploredPoints, focus }) {
return (
<svg
width={WORLD.width}
height={WORLD.height}
className="absolute left-0 top-0"
style={{ overflow: "visible", pointerEvents: "none" }}
>
<defs>
<filter id="fogBlur" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation={90} />
</filter>
<mask id="fogMask">
<rect x={0} y={0} width={WORLD.width} height={WORLD.height} fill="black" />
<g filter="url(#fogBlur)">
{exploredPoints.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r || 420} fill="white" />
))}
{focus && <circle cx={focus.x} cy={focus.y} r={360} fill="white" opacity={0.85} />}
</g>
</mask>
</defs>
<rect
x={0}
y={0}
width={WORLD.width}
height={WORLD.height}
fill="#0B2436"
fillOpacity={0.5}
mask="url(#fogMask)"
/>
</svg>
);
}
```
---
### `src/components/wiki/WorldChrome.jsx`
```jsx
import React from "react";
import { ZoomIn, ZoomOut, Globe2, Compass } from "lucide-react";
import { worldToLatLon } from "@/lib/worldMap";
const STEPS = [50, 100, 200, 400, 800, 1600, 3200];
// Google-Earth-style overlay chrome: coordinate readout, scale bar, zoom controls, compass.
export default function WorldChrome({ focus, scale, viewport, onZoomIn, onZoomOut, onFlyWorld }) {
const { lat, lon } = worldToLatLon(focus.x, focus.y);
let barWorld = STEPS[0];
for (const s of STEPS) if (s * scale >= 50) barWorld = s;
const barPx = Math.max(28, barWorld * scale);
const barKm = Math.round(barWorld * 3);
const btn =
"w-10 h-10 bg-white ink-stroke pill toy-shadow-sm flex items-center justify-center hover:translate-y-[-2px] transition-transform";
return (
<div className="absolute inset-0 pointer-events-none z-30" onPointerDown={(e) => e.stopPropagation()}>
{/* coordinate readout */}
<div className="absolute left-3 top-3 bg-white/90 ink-stroke pill toy-shadow-sm px-3 py-1.5 font-mono text-[11px] text-slate-deep pointer-events-auto">
{Math.abs(lat).toFixed(2)}° {lat >= 0 ? "N" : "S"} · {Math.abs(lon).toFixed(2)}° {lon >= 0 ? "E" : "W"}
</div>
{/* compass */}
<div className="absolute left-1/2 -translate-x-1/2 top-3 flex items-center gap-1.5 bg-white/90 ink-stroke pill toy-shadow-sm px-2.5 py-1.5">
<Compass className="w-4 h-4 text-igloo" />
<span className="text-[10px] font-bold text-slate-deep">N</span>
</div>
{/* scale bar */}
<div className="absolute left-3 bottom-6 flex flex-col gap-1">
<div className="text-[10px] font-bold text-slate-deep bg-white/80 px-1 rounded">{barKm} km</div>
<div className="h-2.5 border-[3px] border-ink bg-white" style={{ width: barPx }} />
</div>
{/* zoom controls */}
<div className="absolute right-3 bottom-6 flex flex-col gap-2 pointer-events-auto">
<button onClick={onZoomIn} className={btn} title="Zoom in">
<ZoomIn className="w-5 h-5 text-slate-deep" />
</button>
<button onClick={onZoomOut} className={btn} title="Zoom out">
<ZoomOut className="w-5 h-5 text-slate-deep" />
</button>
<button onClick={onFlyWorld} className={`${btn} bg-penguin`} title="Zoom out to world">
<Globe2 className="w-5 h-5 text-slate-deep" />
</button>
</div>
</div>
);
}
```
---
### `src/components/wiki/WorldMinimap.jsx`
```jsx
import React from "react";
import { WORLD, DOMAINS, blobPath, hashStr } from "@/lib/worldMap";
const SIZE = 176;
const SX = SIZE / WORLD.width;
const SY = SIZE / WORLD.height;
// Overview minimap: whole world + node dots + viewport rectangle. Click to pan.
export default function WorldMinimap({ nodePoints, focus, scale, viewport, onPick }) {
const vx = (focus.x - viewport.w / (2 scale)) SX;
const vy = (focus.y - viewport.h / (2 scale)) SY;
const vw = (viewport.w / scale) * SX;
const vh = (viewport.h / scale) * SY;
const handleClick = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = ((e.clientX - rect.left) / SIZE) * WORLD.width;
const my = ((e.clientY - rect.top) / SIZE) * WORLD.height;
onPick(mx, my);
};
return (
<div
className="absolute right-3 top-3 z-30 bg-white/90 ink-stroke pill toy-shadow-sm p-1.5"
onPointerDown={(e) => e.stopPropagation()}
>
<svg
width={SIZE}
height={SIZE}
onClick={handleClick}
className="cursor-pointer rounded-xl block"
style={{ background: "#A6D6EE" }}
>
{DOMAINS.map((d) => (
<path
key={d.key}
fill="#EDE6CF"
stroke={d.color}
strokeOpacity={0.5}
strokeWidth={1.5}
/>
))}
{nodePoints.map((p) => (
<circle key={p.id} cx={p.x SX} cy={p.y SY} r={1.6} fill={p.domainColor} stroke="#1E293B" strokeWidth={0.4} />
))}
<rect
x={vx}
y={vy}
width={vw}
height={vh}
fill="#06B6D4"
fillOpacity={0.12}
stroke="#06B6D4"
strokeWidth={1.5}
strokeDasharray="3 2"
/>
</svg>
<div className="text-[9px] font-bold uppercase text-center text-slate-deep pt-0.5 tracking-wide">Atlas</div>
</div>
);
}
```
---
### `src/components/wiki/WorldNodeMarker.jsx`
```jsx
import React from "react";
// A place-marker for a wiki node on the world. Counter-scaled so it stays a
// constant screen size regardless of zoom (Google-Earth labels behave alike).
export default function WorldNodeMarker({ node, pos, scale, active, onSelect }) {
const compact = scale < 0.5;
const counter = 1 / scale;
return (
<div
className="absolute"
style={{
left: pos.x,
top: pos.y,
transform: `translate(-50%, -50%) scale(${counter})`,
transformOrigin: "center",
zIndex: active ? 60 : 25,
}}
onPointerDown={(e) => e.stopPropagation()}
>
<button onClick={(e) => { e.stopPropagation(); onSelect(node); }} className="block group focus:outline-none">
{compact ? (
<div className="relative flex flex-col items-center">
<div
className={`w-4 h-4 rounded-full ink-stroke ${active ? "ring-4 ring-igloo" : ""}`}
style={{ background: pos.domainColor }}
/>
<div className="absolute top-full mt-1 left-1/2 -translate-x-1/2 whitespace-nowrap bg-ink text-white text-[10px] font-bold px-1.5 py-0.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
{node.title}
</div>
</div>
) : (
<div className="relative flex flex-col items-center">
<div
className={`flex items-center gap-1.5 bg-white ink-stroke pill toy-shadow-sm px-2.5 py-1.5 ${
active ? "ring-4 ring-igloo toy-shadow-lg" : ""
}`}
>
<span className="text-base leading-none">{node.icon || "📍"}</span>
<span className="text-[12px] font-bold text-slate-deep whitespace-nowrap">{node.title}</span>
</div>
<div className="w-3.5 h-3.5 rotate-45 bg-white border-r-[3px] border-b-[3px] border-ink -mt-2" />
</div>
)}
</button>
</div>
);
}
```
---
### `src/components/wiki/ExplorerHud.jsx`
```jsx
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Award, Brain, ChevronUp, Flag, Gauge, Layers, MapPin, Trophy } from "lucide-react";
import { levelFromXp, missionProgress, ACHIEVEMENTS, visitedCount } from "@/lib/explorerProgress";
function LevelRing({ level, pct }) {
const r = 18;
const c = 2 Math.PI r;
return (
<div className="relative w-12 h-12 shrink-0">
<svg width={48} height={48} className="-rotate-90">
<circle cx={24} cy={24} r={r} fill="none" stroke="#E6EEF6" strokeWidth={5} />
<circle cx={24} cy={24} r={r} fill="none" stroke="#06B6D4" strokeWidth={5} strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - pct)} />
</svg>
<div className="absolute inset-0 flex items-center justify-center text-sm font-display font-bold text-slate-deep">{level}</div>
</div>
);
}
function fmtTime(ms) {
const s = Math.ceil(ms / 1000);
const m = Math.floor(s / 60);
const r = s % 60;
return `m:{r.toString().padStart(2, "0")}`;
}
export default function ExplorerHud({ progress, onResearch, researchAvailable }) {
const [openAch, setOpenAch] = useState(false);
const [, setNow] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const { level, status, pct, isMax } = levelFromXp(progress.xp || 0);
const mp = missionProgress(progress);
const unlocked = new Set(progress.achievements || []);
return (
<>
<div className="absolute left-1/2 -translate-x-1/2 bottom-24 z-30 w-[min(94vw,720px)]">
<div className="flex items-center gap-2 sm:gap-3 bg-white/92 ink-stroke pill toy-shadow px-2.5 py-2 flex-wrap">
<div className="flex items-center gap-2">
<LevelRing level={level} pct={pct} />
<div className="leading-tight">
<div className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">{isMax ? "Max" : `Lvl ${level}`}</div>
<div className="text-xs font-display font-bold text-slate-deep">{status}</div>
</div>
</div>
<div className="hidden sm:flex flex-col gap-1 min-w-[120px]">
<div className="flex items-center justify-between text-[9px] font-bold text-muted-foreground">
<span>XP</span>
<span>{progress.xp || 0}</span>
</div>
<div className="h-2 rounded-full bg-canvas border-2 border-ink overflow-hidden">
<div className="h-full bg-igloo" style={{ width: `${Math.round(pct * 100)}%` }} />
</div>
</div>
{/* expedition */}
{progress.mission && (
<div className="flex items-center gap-2 bg-canvas border-2 border-ink rounded-xl px-2 py-1 min-w-[150px]">
<Flag className="w-3.5 h-3.5 shrink-0" style={{ color: progress.mission.domainColor }} />
<div className="leading-tight min-w-0">
<div className="text-[9px] font-bold uppercase text-muted-foreground truncate">Expedition</div>
<div className="text-[10px] font-bold text-slate-deep truncate">
{mp.progress}/{progress.mission.target} · {progress.mission.domainName}
</div>
<div className="h-1 rounded-full bg-white border border-ink overflow-hidden mt-0.5">
<div className="h-full" style={{ width: `${Math.min(100, (mp.progress / progress.mission.target) * 100)}%`, background: progress.mission.domainColor }} />
</div>
</div>
<div className="text-[9px] font-mono font-bold text-slate-deep shrink-0">{fmtTime(mp.remaining)}</div>
</div>
)}
<div className="flex items-center gap-1.5 ml-auto">
<button
onClick={onResearch}
disabled={!researchAvailable}
className="flex items-center gap-1 text-[11px] font-bold text-white bg-igloo ink-stroke rounded-lg px-2.5 py-1.5 disabled:opacity-40 hover:translate-y-[-1px] transition-transform"
title="Deep research the current place"
>
<Brain className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Deep Research</span>
</button>
<button
onClick={() => setOpenAch(true)}
className="flex items-center gap-1 text-[11px] font-bold text-slate-deep bg-penguin ink-stroke rounded-lg px-2.5 py-1.5 hover:translate-y-[-1px] transition-transform"
title="Achievements & stats"
>
<Trophy className="w-3.5 h-3.5" /> <span className="hidden sm:inline">{unlocked.size}/{ACHIEVEMENTS.length}</span>
</button>
</div>
</div>
</div>
<Dialog open={openAch} onOpenChange={setOpenAch}>
<DialogContent className="max-w-lg rounded-2xl border-[3px] border-ink toy-shadow">
<DialogHeader>
<DialogTitle className="font-display font-bold flex items-center gap-2">
<Trophy className="w-5 h-5 text-penguin" /> Explorer Record
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3">
<Stat icon={MapPin} label="Places" value={visitedCount(progress)} />
<Stat icon={Layers} label="Layers" value={progress.pinsOpened || 0} />
<Stat icon={Gauge} label="Max Depth" value={`${(progress.maxDepth || 1).toFixed(1)}×`} />
<Stat icon={Brain} label="Deep Dives" value={progress.deepDives || 0} />
</div>
<div className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground mb-1.5">Achievements</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[50vh] overflow-y-auto scrollbar-thin pr-1">
{ACHIEVEMENTS.map((a) => {
const got = unlocked.has(a.id);
return (
<div key={a.id} className={`flex items-center gap-2 border-2 border-ink rounded-xl p-2 ${got ? "bg-penguin/30" : "bg-canvas opacity-70"}`}>
<div className={`w-8 h-8 rounded-lg ink-stroke flex items-center justify-center shrink-0 ${got ? "bg-penguin" : "bg-white"}`}>
<Award className={`w-4 h-4 ${got ? "text-slate-deep" : "text-muted-foreground"}`} />
</div>
<div className="min-w-0">
<div className="text-xs font-bold text-slate-deep truncate">{a.title}</div>
<div className="text-[10px] text-muted-foreground truncate">{a.desc} · +{a.xp} XP</div>
</div>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
</>
);
}
function Stat({ icon: Icon, label, value }) {
return (
<div className="bg-canvas border-2 border-ink rounded-xl p-2 text-center">
<Icon className="w-4 h-4 text-igloo mx-auto mb-0.5" />
<div className="text-sm font-display font-bold text-slate-deep">{value}</div>
<div className="text-[9px] font-bold uppercase text-muted-foreground">{label}</div>
</div>
);
}
```
---
### `src/components/wiki/DeepResearchPanel.jsx`
```jsx
import React from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Brain, ChevronRight, Compass, Loader2, Microscope } from "lucide-react";
// Layered, internet-sourced deep research for a place. Each subtopic can be
// expanded ("go deeper") and each pathway can be explored.
export default function DeepResearchPanel({ open, loading, data, topic, depth, onClose, onGoDeeper, onPathway }) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl rounded-2xl border-[3px] border-ink toy-shadow max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="font-display font-bold flex items-center gap-2">
<Microscope className="w-5 h-5 text-igloo" />
Deep Research
<span className="ml-auto flex items-center gap-1 text-[11px] font-bold text-muted-foreground">
<Compass className="w-3.5 h-3.5" /> Depth {data?.depthLevel || depth || 1}
</span>
</DialogTitle>
<DialogDescription className="text-slate-deep font-semibold">{topic}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto scrollbar-thin pr-1 -mr-1">
{loading && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Loader2 className="w-7 h-7 animate-spin text-igloo" />
<span className="text-sm font-semibold">Pulling the deepest public research on "{topic}"…</span>
</div>
)}
{!loading && data && (
<>
{data.summary && (
<p className="text-sm text-slate-deep leading-relaxed bg-canvas border-2 border-ink rounded-xl p-3 mb-3">
{data.summary}
</p>
)}
<div className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground mb-1.5">Branches</div>
<div className="flex flex-col gap-2 mb-3">
{(data.subtopics || []).map((s, i) => (
<div key={i} className="bg-white border-2 border-ink rounded-xl p-3">
<div className="flex items-center gap-1.5 mb-1">
<span className="w-6 h-6 rounded-lg bg-igloo text-white text-[11px] font-bold flex items-center justify-center">{i + 1}</span>
<div className="text-sm font-display font-bold text-slate-deep">{s.title}</div>
</div>
<p className="text-xs text-slate-deep leading-snug mb-1.5">{s.detail}</p>
{(s.facts || []).length > 0 && (
<ul className="text-[11px] text-slate-deep list-disc pl-4 space-y-0.5 mb-1.5">
{s.facts.map((f, j) => (<li key={j}>{f}</li>))}
</ul>
)}
{(s.deeper || []).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{s.deeper.map((d, j) => (
<button
key={j}
onClick={() => onGoDeeper(d)}
className="flex items-center gap-1 text-[10px] font-bold text-igloo bg-canvas border-2 border-ink rounded-lg px-2 py-1 hover:translate-y-[-1px] transition-transform"
>
<Brain className="w-3 h-3" /> Go deeper: {d}
</button>
))}
</div>
)}
</div>
))}
</div>
{(data.pathways || []).length > 0 && (
<>
<div className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground mb-1.5">Pathways onward</div>
<div className="flex flex-wrap gap-1.5">
{data.pathways.map((p, i) => (
<button
key={i}
onClick={() => onPathway(p.title)}
title={p.why}
className="flex items-center gap-1 text-[11px] font-bold text-slate-deep bg-penguin/30 border-2 border-ink rounded-lg px-2.5 py-1.5 hover:translate-y-[-1px] transition-transform"
>
{p.title} <ChevronRight className="w-3.5 h-3.5" />
</button>
))}
</div>
</>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
```
---
### `src/pages/Home.jsx`
```jsx
import React, { useEffect, useState, useCallback, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useToast } from "@/components/ui/use-toast";
import { useIsMobile } from "@/hooks/use-mobile";
import TopToolbar from "@/components/wiki/TopToolbar";
import MiniRail from "@/components/wiki/MiniRail";
import WorldMap from "@/components/wiki/WorldMap";
import DiscussionRail from "@/components/wiki/DiscussionRail";
import CreationDock from "@/components/wiki/CreationDock";
import AddPinDialog from "@/components/wiki/AddPinDialog";
import MobilePinSheet from "@/components/wiki/MobilePinSheet";
import ExplorerHud from "@/components/wiki/ExplorerHud";
import DeepResearchPanel from "@/components/wiki/DeepResearchPanel";
import { exportLivingPdf } from "@/lib/wikiPdf";
import { getNodePositions, classifyDomain, DOMAINS } from "@/lib/worldMap";
import {
load, save, recordVisit, recordPin, recordComment, recordDepth, recordDeepDive,
levelFromXp, missionProgress, generateMission,
} from "@/lib/explorerProgress";
const NODE_COLORS = ["#06B6D4", "#FFD000", "#FF5252", "#0F172A"];
const NODE_ICONS = ["📘", "🐧", "🧊", "🗺️", "🔬", "🎨", "🚀"];
export default function Home() {
const { toast } = useToast();
const isMobile = useIsMobile();
const [nodes, setNodes] = useState([]);
const [activeNodeId, setActiveNodeId] = useState(null);
const [pins, setPins] = useState([]);
const [activePin, setActivePin] = useState(null);
const [comments, setComments] = useState([]);
const [saved, setSaved] = useState(true);
const [railCollapsed, setRailCollapsed] = useState(false);
const [dialog, setDialog] = useState({ open: false, type: "text", editing: null });
const [progress, setProgress] = useState(() => load());
const [research, setResearch] = useState({ open: false, loading: false, data: null, topic: null, depth: 1 });
const markSaving = () => { setSaved(false); setTimeout(() => setSaved(true), 900); };
const nodePoints = useMemo(() => getNodePositions(nodes), [nodes]);
// load nodes
const loadNodes = useCallback(async () => {
try {
const list = await base44.entities.WikiNode.list();
setNodes(list);
if (list.length && !activeNodeId) setActiveNodeId(list[0].id);
} catch (e) { console.error(e); }
}, []);
useEffect(() => { loadNodes(); }, [loadNodes]);
// persist progress
useEffect(() => { save(progress); }, [progress]);
// idle mission expiry tick
useEffect(() => {
const t = setInterval(() => {
setProgress((p) => {
if (!p.mission) return p;
const mp = missionProgress(p);
if (mp.status === "expired") {
const np = structuredClone(p);
np.mission = generateMission(np);
return save(np);
}
return p;
});
}, 1000);
return () => clearInterval(t);
}, []);
// load pins for active node + subscribe
useEffect(() => {
if (!activeNodeId) return;
let cancelled = false;
const load = async () => {
try {
const list = await base44.entities.Pin.filter({ node_id: activeNodeId });
if (!cancelled) setPins(list);
} catch (e) { console.error(e); }
};
load();
const unsub = base44.entities.Pin.subscribe((event) => {
setPins((prev) => {
if (event.type === "create") return [...prev, event.data];
if (event.type === "update") return prev.map((p) => (p.id === event.data.id ? event.data : p));
return prev;
});
});
return () => { cancelled = true; unsub(); };
}, [activeNodeId]);
// load comments for active pin + subscribe
useEffect(() => {
if (!activePin) { setComments([]); return; }
let cancelled = false;
const load = async () => {
try {
const list = await base44.entities.Comment.filter({ pin_id: activePin.id });
if (!cancelled) setComments(list);
} catch (e) { console.error(e); }
};
load();
const unsub = base44.entities.Comment.subscribe((event) => {
setComments((prev) => {
if (event.data?.pin_id !== activePin.id && event.type !== "delete") return prev;
if (event.type === "create") return [...prev, event.data];
if (event.type === "update") return prev.map((c) => (c.id === event.data.id ? event.data : c));
return prev;
});
});
return () => { cancelled = true; unsub(); };
}, [activePin]);
const activeNode = nodes.find((n) => n.id === activeNodeId);
// ---- gamification helpers ----
const notify = (res, np, beforeLevel) => {
if (!res) return;
const after = levelFromXp(np.xp).level;
if (after > beforeLevel) {
toast({ title: `Level ${after} reached!`, description: `You are now a ${levelFromXp(np.xp).status}.` });
}
(res.unlocked || []).forEach((a) => toast({ title: `Achievement: a.title`,description:`{a.desc} (+${a.xp} XP)` }));
if (res.missionCompleted) toast({ title: "Expedition complete!", description: "Bonus XP earned — a new expedition awaits." });
};
const handleVisit = useCallback((node, depth) => {
setProgress((p) => {
const before = levelFromXp(p.xp).level;
const np = structuredClone(p);
const res = recordVisit(np, { nodeId: node.id, domain: classifyDomain(node.title), depth: depth || 1 });
const savedNp = save(np);
setTimeout(() => notify(res, savedNp, before), 0);
return savedNp;
});
}, []);
const handleOpenPin = useCallback((pin) => {
setProgress((p) => {
const before = levelFromXp(p.xp).level;
const np = structuredClone(p);
const r1 = recordPin(np);
const r2 = recordComment(np);
const savedNp = save(np);
setTimeout(() => notify({ ...r1, unlocked: [...(r1.unlocked || []), ...(r2.unlocked || [])], missionCompleted: r1.missionCompleted || r2.missionCompleted }, savedNp, before), 0);
return savedNp;
});
}, []);
const handleZoom = useCallback((scale) => {
setProgress((p) => {
if (scale <= (p.maxDepth || 1)) return p;
const before = levelFromXp(p.xp).level;
const np = structuredClone(p);
const res = recordDepth(np, scale);
const savedNp = save(np);
setTimeout(() => notify(res, savedNp, before), 0);
return savedNp;
});
}, []);
// ---- deep research ----
const topInterests = useCallback(() => {
return Object.entries(progress.domainsVisited || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([k]) => DOMAINS.find((d) => d.key === k)?.name)
.filter(Boolean);
}, [progress.domainsVisited]);
const runResearch = useCallback(async (topic, depth) => {
const pos = activeNodeId ? nodePoints.get(activeNodeId) : null;
const domain = pos?.domainName || "general";
const interests = topInterests();
setResearch({ open: true, loading: true, data: null, topic, depth });
try {
const res = await base44.functions.invoke("DeepResearch", { topic, domain, depth, interests });
const data = res?.data?.result || res?.data;
setResearch({ open: true, loading: false, data, topic, depth });
setProgress((p) => {
const before = levelFromXp(p.xp).level;
const np = structuredClone(p);
const r = recordDeepDive(np);
const savedNp = save(np);
setTimeout(() => notify(r, savedNp, before), 0);
return savedNp;
});
} catch (e) {
console.error(e);
setResearch((r) => ({ ...r, loading: false }));
toast({ title: "Deep research failed", variant: "destructive" });
}
}, [activeNodeId, nodePoints, topInterests]);
const handleGoDeeper = useCallback((sub) => {
runResearch(sub, (research.depth || 1) + 1);
}, [runResearch, research.depth]);
const handlePathway = useCallback((title) => {
setResearch((r) => ({ ...r, open: false }));
const t = title.toLowerCase();
const match = nodes.find((n) => n.title.toLowerCase().includes(t) || t.includes(n.title.toLowerCase()));
if (match) { setActiveNodeId(match.id); setActivePin(null); }
else toast({ title: `Pathway: ${title}`, description: "A new branch to explore — run Deep Research from a place to follow it." });
}, [nodes, toast]);
// ---- actions ----
const handleAdd = (type) => setDialog({ open: true, type, editing: null });
const handleSubmitPin = async (data) => {
markSaving();
try {
if (dialog.editing) {
await base44.entities.Pin.update(dialog.editing.id, {
title: data.title, body: data.body, media_url: data.media_url, author_name: data.author_name,
});
toast({ title: "Layer overridden", description: "The original model has been updated." });
} else {
const maxLayer = pins.reduce((m, p) => Math.max(m, p.layer || 0), 0);
await base44.entities.Pin.create({
node_id: activeNodeId,
type: dialog.type,
title: data.title,
body: data.body,
media_url: data.media_url,
layer: maxLayer + 1,
pos_x: 20 + Math.random() * 60,
pos_y: 20 + Math.random() * 55,
author_name: data.author_name,
});
toast({ title: "New layer added", description: `Layer ${maxLayer + 1} pinned to the wiki.` });
}
setDialog({ open: false, type: "text", editing: null });
} catch (e) {
console.error(e);
toast({ title: "Something went wrong", variant: "destructive" });
}
};
const handleDeletePin = async (id) => {
markSaving();
try {
await base44.entities.Pin.delete(id);
if (activePin?.id === id) setActivePin(null);
toast({ title: "Layer removed" });
} catch (e) { console.error(e); }
};
const handleEditPin = (pin) => setDialog({ open: true, type: pin.type, editing: pin });
const handleReply = async (parentId, body) => {
if (!activePin) return;
markSaving();
try {
await base44.entities.Comment.create({
pin_id: activePin.id,
parent_id: parentId || undefined,
body,
author_name: "anonymous penguin",
upvotes: 0,
});
} catch (e) { console.error(e); }
};
const handleUpvote = async (id) => {
const c = comments.find((x) => x.id === id);
if (!c) return;
try {
await base44.entities.Comment.update(id, { upvotes: (c.upvotes || 0) + 1 });
} catch (e) { console.error(e); }
};
const handleAddNode = async () => {
markSaving();
try {
const n = await base44.entities.WikiNode.create({
title: `New Node ${nodes.length + 1}`,
summary: "A fresh wiki page — add layers to grow it.",
icon: NODE_ICONS[nodes.length % NODE_ICONS.length],
color: NODE_COLORS[nodes.length % NODE_COLORS.length],
});
setNodes((prev) => [...prev, n]);
setActiveNodeId(n.id);
setActivePin(null);
} catch (e) { console.error(e); }
};
const handleDownload = () => {
const byPin = {};
.then((results) => {
pins.forEach((p, i) => { byPin[p.id] = results[i]; });
exportLivingPdf(activeNode, pins, byPin);
toast({ title: "Living PDF downloaded", description: "Re-open in WikiPuff to keep adding layers." });
});
};
const visitedIds = useMemo(() => Object.keys(progress.visited || {}), [progress.visited]);
return (
<div className="relative w-full h-screen overflow-hidden bg-canvas">
<TopToolbar node={activeNode} saved={saved} onDownload={handleDownload} isMobile={isMobile} />
<MiniRail
nodes={nodes}
activeId={activeNodeId}
onSelect={(id) => { setActiveNodeId(id); setActivePin(null); }}
onAddNode={handleAddNode}
collapsed={railCollapsed}
setCollapsed={setRailCollapsed}
/>
<div className="absolute inset-0 lg:right-[22rem]">
<WorldMap
nodes={nodes}
activeNodeId={activeNodeId}
pins={pins}
activePinId={activePin?.id}
onSelectNode={(node) => { setActiveNodeId(node.id); setActivePin(null); }}
onSelectPin={(p) => setActivePin(p)}
onStageClick={() => setActivePin(null)}
visitedIds={visitedIds}
onVisit={handleVisit}
onOpenPin={handleOpenPin}
onZoomChange={handleZoom}
/>
</div>
<ExplorerHud
progress={progress}
researchAvailable={!!activeNode}
onResearch={() => activeNode && runResearch(activeNode.title, 1)}
/>
<DiscussionRail
pin={activePin}
comments={comments}
onClose={() => setActivePin(null)}
onReply={handleReply}
onUpvote={handleUpvote}
onDelete={handleDeletePin}
onEdit={handleEditPin}
/>
<CreationDock onAdd={handleAdd} isMobile={isMobile} />
<MobilePinSheet
open={!!activePin && isMobile}
pin={activePin}
comments={comments}
onClose={() => setActivePin(null)}
onReply={handleReply}
onUpvote={handleUpvote}
onDelete={handleDeletePin}
onEdit={handleEditPin}
/>
<AddPinDialog
open={dialog.open}
type={dialog.type}
pin={dialog.editing}
onClose={() => setDialog({ open: false, type: "text", editing: null })}
onSubmit={handleSubmitPin}
/>
<DeepResearchPanel
open={research.open}
loading={research.loading}
data={research.data}
topic={research.topic}
depth={research.depth}
onClose={() => setResearch((r) => ({ ...r, open: false }))}
onGoDeeper={handleGoDeeper}
onPathway={handlePathway}
/>
</div>
);
}
```
---
### `base44/functions/DeepResearch/entry.ts`
```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.52';
// Deep, internet-sourced research for a place on the atlas. Biases the
// branches toward the explorer's recent interests and goes deeper on demand.
export default async function(req) {
try {
const base44 = createClientFromRequest(req);
const body = await req.json().catch(() => ({}));
const topic = String(body.topic || "").slice(0, 160).trim();
const domain = String(body.domain || "").slice(0, 60);
const depth = Math.max(1, Math.min(4, Number(body.depth) || 1));
const interests = Array.isArray(body.interests) ? body.interests.slice(0, 6).map((s) => String(s).slice(0, 60)) : [];
if (!topic) return Response.json({ error: "topic required" }, { status: 400 });
const interestLine = interests.length
? `The explorer has been spending time in: ${interests.join(", ")}. Where natural, bias the branches and pathways toward those interests so the world deepens in the direction they are heading.`
: "";
const depthGuide =
depth >= 4 ? "Go to specialist depth: precise terminology, mechanisms, edge cases, open questions, and lesser-known specifics a dedicated researcher would value."
: depth === 3 ? "Go to advanced depth: structured subtopics with concrete, specific facts and mechanisms."
: depth === 2 ? "Go to intermediate depth: structured subtopics with solid concrete facts."
: "Give a clear, engaging overview with a few solid subtopics.";
const prompt = `You are a deep-research engine for an explorable knowledge atlas ("WikiPuff"). The explorer is at the place "${topic}" (domain: ${domain || "general"}). ${interestLine} ${depthGuide} Pull from the deepest, most specific public information available on the internet — real specifics, not generic summaries. Return JSON with: a one-paragraph "summary"; "subtopics" (array of {title, detail (2-4 sentences), facts (3-5 specific verifiable facts), deeper (2-3 more specific sub-areas to explore next)}); "pathways" (2-4 {title, why} adjacent topics worth exploring, biased toward the explorer's interests); "depthLevel" (1-4 you actually reached). Be substantive and specific.`;
const schema = {
type: "object",
properties: {
summary: { type: "string" },
subtopics: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
detail: { type: "string" },
facts: { type: "array", items: { type: "string" } },
deeper: { type: "array", items: { type: "string" } },
},
required: ["title", "detail", "facts", "deeper"],
},
},
pathways: {
type: "array",
items: {
type: "object",
properties: { title: { type: "string" }, why: { type: "string" } },
required: ["title", "why"],
},
},
depthLevel: { type: "number" },
},
required: ["summary", "subtopics", "pathways", "depthLevel"],
};
const res = await base44.asServiceRole.integrations.Core.InvokeLLM({
prompt,
add_context_from_internet: true,
model: "gemini_3_8_flash",
response_json_schema: schema,
});
return Response.json({ result: res });
} catch (error) {
return Response.json({ error: error.message }, { status: 500 });
}
}
```
Comments