// DiveDex — UI primitives + bottom nav + top bar function Logo({ size = 28, mark = true, word = true, gold = false, name = 'DiveDex' }) { const accent = gold ? '#D7B46A' : '#82ECFF'; const accent2 = gold ? '#FBE6A6' : '#31D7FF'; return (
{mark && ( )} {word && ( DiveDex )}
); } function SonarRing({ size = 220, pulses = 3 }) { return (
{Array.from({ length: pulses }).map((_, i) => (
))}
); } function GlassCard({ children, style = {}, strong = false, onClick, accent }) { return (
{children}
); } function Chip({ children, variant = 'default', icon, style }) { const cls = { default: 'dd-chip', aqua: 'dd-chip dd-chip-aqua', gold: 'dd-chip dd-chip-gold', coral: 'dd-chip dd-chip-coral', foam: 'dd-chip dd-chip-foam', }[variant]; return {icon}{children}; } function XPBar({ value, max, color = 'var(--aqua)', height = 8, label, sub, animate = true }) { const pct = Math.min(100, (value / max) * 100); return (
{(label || sub) && (
{label} {sub}
)}
); } function RarityRing({ rarity, size = 86, children }) { const isMythic = rarity === 'mythic'; return (
{children}
); } // Bottom navigation — 5 tabs function BottomNav({ tab, onTab }) { const items = [ { id: 'levels', label: 'Levels', icon: }, { id: 'log', label: 'Log', icon: }, { id: 'dex', label: 'Dex', icon: }, { id: 'team', label: 'Team', icon: }, { id: 'memories', label: 'Memories', icon: }, { id: 'gear', label: 'Gear', icon: }, ]; return (
{items.map(it => { const active = tab === it.id; return ( ); })}
); } function NavIcon({ kind }) { const s = { width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }; if (kind === 'levels') return ; if (kind === 'log') return ; if (kind === 'dex') return ; if (kind === 'team') return ; if (kind === 'memories') return ; if (kind === 'gear') return ; } function TopBar({ title, sub, left, right, scroll = 0 }) { // Always-on blurred backdrop so scrolling content doesn't bleed visibly // through the sticky header. The `scroll` prop is no longer required — // kept for back-compat with any caller still passing it. // // The TopBar owns the device safe-area-inset-top: its background extends // all the way to the top of the viewport (under the iOS Dynamic Island / // notch), and its internal top padding is `14 + safe-area-inset-top` so // the title sits *below* the status bar. Sticky `top: 0` then keeps it // pinned to the very top of the scrollport, with no double-padding from // the scroll container. In a browser tab or on desktop the env value // resolves to 0 so this collapses to a normal 14px top padding. return (
{left}
{title}
{sub &&
{sub}
}
{right}
); } function IconBtn({ children, onClick }) { return ( ); } function ProgressDots({ step, total }) { return (
{Array.from({ length: total }).map((_, i) => (
))}
); } function RarityFrame({ rarity, children, style }) { return (
{children}
); } function StatCell({ label, value, unit, color = '#EAF5FF', mono = true }) { return (
{label}
{value} {unit && {unit}}
); } function Avatar({ name, color, size = 28 }) { return (
{name[0]}
); } function PhotoBlock({ ratio = '4 / 3', label = 'photo', style = {}, tint = 'rgba(49,215,255,0.06)' }) { return (
{label}
); } // DiveSiteMap — stylized regional chart with dive-site pins. // // Props: // region: region key (e.g. 'japan') — picks the bounding box + label // sites: [{ name, lat, lng, highlight?: bool }] — markers to render // height: pixel height of the rendered map (default 200) // onSiteTap: optional click handler invoked with the tapped site name // // The silhouette is hand-drawn for vibe (it's not survey-accurate); the // markers project real lat/lng into the same coordinate space so the // relative positions of dive sites are correct. function DiveSiteMap({ region, sites, height = 200, onSiteTap }) { const bounds = (window.DD_STATE && window.DD_STATE.REGION_MAP_BOUNDS && window.DD_STATE.REGION_MAP_BOUNDS[region]) || null; if (!bounds || !sites || sites.length === 0) return null; const VBW = 200, VBH = 250; const project = (lat, lng) => { const x = ((lng - bounds.minLng) / (bounds.maxLng - bounds.minLng)) * VBW; const y = VBH - ((lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * VBH; return [x, y]; }; // Clamp markers to viewBox with a small inset so they never sit on the edge. const inset = 10; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const projected = sites.map(s => { const [x, y] = project(s.lat, s.lng); return { ...s, x: clamp(x, inset, VBW - inset), y: clamp(y, inset, VBH - inset) }; }); // Coastline silhouettes — one per region. They're decorative; if a region // doesn't have one yet, we fall back to "no land", which still looks fine // because the markers carry the meaning. const LAND_PATHS = { 'japan': 'M8,8 L192,8 L192,55 Q180,75 170,90 Q162,110 158,140 Q150,175 130,200 ' + 'Q100,225 65,220 Q38,215 30,190 Q22,160 28,140 Q35,125 22,110 ' + 'Q12,98 18,80 Q25,65 22,45 Q20,28 8,18 Z', }; const land = LAND_PATHS[region]; return (
{/* Faint navigational grid */} {/* Land silhouette */} {land && ( )} {/* Compass rose */} N {/* Markers */} {projected.map((s, i) => { const c = s.highlight ? '#D7B46A' : '#82ECFF'; const glow = s.highlight ? 'url(#dd-map-pin-hot)' : 'url(#dd-map-pin-glow)'; return ( onSiteTap(s.name) : undefined}> {s.highlight && ( )} ); })} {/* Label strip */}
{bounds.label} {sites.length} {sites.length === 1 ? 'site' : 'sites'}
); } // Resolve a site name -> { name, lat, lng } using SEED_DIVE_SITE_GEO, or null. function resolveDiveSiteGeo(name) { if (!name) return null; const geo = (window.DD_STATE && window.DD_STATE.SEED_DIVE_SITE_GEO) || {}; const hit = geo[name]; if (!hit) return null; return { name, lat: hit.lat, lng: hit.lng, region: hit.region }; } Object.assign(window, { Logo, SonarRing, GlassCard, Chip, XPBar, RarityRing, BottomNav, NavIcon, TopBar, IconBtn, ProgressDots, RarityFrame, StatCell, Avatar, PhotoBlock, DiveSiteMap, resolveDiveSiteGeo, });