top of page

WikiPuff base44

Sep 30
27 min read









---


### `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

        .filter((q) => q.id !== p.id)

        .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) {

        const id = [p.id, q.id].sort().join("|");

        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) {

  p.events = [...(p.events || []), { t: Date.now(), ...ev }].slice(-200);

}


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 (progress >= m.target) return { progress, target: m.target, remaining, status: "complete" };

  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) => {

          const node = nodes.find((n) => n.id === p.id);

          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) => (

              <PinCard key={pin.id} pin={pin} active={pin.id === activePinId} onSelect={handleSelectPin} />

            ))}

          </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 shadow = blobPath(d.center.x + 18, d.center.y + 22, d.radius, seed + 7, 22);

        const land = blobPath(d.center.x, d.center.y, d.radius, seed, 26);

        const river = riverPath(seed + 3, d.center.x, d.center.y + d.radius * 0.1, d.radius);

        const peaks = mountainPeaks(seed + 5, d.center.x, d.center.y - d.radius * 0.35, d.radius, 8);

        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}

            d={blobPath(d.center.x SX, d.center.y SY, d.radius * SX, hashStr(d.key), 16)}

            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));

        if (event.type === "delete") return prev.filter((p) => p.id !== event.id);

        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));

        if (event.type === "delete") return prev.filter((c) => c.id !== event.id);

        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 = {};

    Promise.all(pins.map((p) => base44.entities.Comment.filter({ pin_id: p.id }).catch(() => [])))

      .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 });

  }

}

```



 
 
 

Recent Posts

See All
Discussing with others

the act of discussing the plan with others is a part of the advertising fun it is like the salesmanship of selling it along the way, getting people involved and getting their phone numbers so that whe

 
 
 
if I have a pathway to transmit

if there is information being transmitted through a medium, and i cant find a new way to promote that same information in a different way then i need to discuss with myself the pathways that i've trie

 
 
 

Comments


bottom of page