const FORMAT_ORDER = ['Movie', 'Anime', 'Anime Movie', 'Drama']; const FORMAT_ANCHORS = { Movie: [0.26, 0.28], Anime: [0.74, 0.28], 'Anime Movie': [0.29, 0.72], Drama: [0.71, 0.72], Other: [0.5, 0.24], }; const FORMAT_COLORS = { Movie: '#7dcbd7', Anime: '#b6a0e3', 'Anime Movie': '#e0a0ad', Drama: '#dfc48d', }; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const stringValue = value => typeof value === 'string' ? value : ''; function hash32(value) { let hash = 2166136261; for (const char of String(value)) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619); return hash >>> 0; } function unitHash(value) { return hash32(value) / 0x100000000; } function rgba(hex, alpha) { const value = Number.parseInt(hex.slice(1), 16); return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`; } function canonicalFormat(value) { const text = stringValue(value).trim(); return FORMAT_ORDER.find(format => format.toLocaleLowerCase('en') === text.toLocaleLowerCase('en')) || ''; } function classifyStatus(value) { if (value == null || (typeof value === 'string' && !value.trim())) return 'blank'; if (typeof value !== 'string') return 'unknown'; const status = value.normalize('NFKC').trim(); if (status === '視聴済み') return 'watched'; if (status === '未視聴') return 'unwatched'; return 'unknown'; } function formatTime(value) { if (!value) return ''; const time = new Date(value); if (Number.isNaN(time.getTime())) return ''; return time.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); } /** Stable normalized position for one saved work. It does not depend on list size. */ export function workStarPosition(id, format) { const canonical = canonicalFormat(format); const [centerX, centerY] = FORMAT_ANCHORS[canonical] || FORMAT_ANCHORS.Other; const key = `${canonical || stringValue(format)}\u0000${String(id ?? '')}`; const angle = unitHash(`${key}:angle`) * Math.PI * 2; const radius = 0.015 + Math.sqrt(unitHash(`${key}:radius`)) * 0.22; return { x: clamp(centerX + Math.cos(angle) * radius, 0.025, 0.975), y: clamp(centerY + Math.sin(angle) * radius * 0.72, 0.04, 0.96), }; } /** Normalize the public Home payload without mutating it or inventing watched state. */ export function buildUniverseModel(payload) { const input = payload && typeof payload === 'object' ? payload : {}; const hasWorks = Array.isArray(input.works); let malformed = 0; let duplicateCount = 0; let watchedCount = 0; let blankStatusCount = 0; let unknownStatusCount = 0; const byFormat = Object.fromEntries(FORMAT_ORDER.map(format => [format, 0])); const watchedByFormat = Object.fromEntries(FORMAT_ORDER.map(format => [format, 0])); const otherFormats = new Map(); const works = []; const seen = new Set(); if (hasWorks) for (const work of input.works) { if (!work || typeof work !== 'object' || Array.isArray(work)) { malformed++; continue; } const rawId = work.id; if (rawId == null || (typeof rawId !== 'string' && typeof rawId !== 'number') || !String(rawId).trim()) { malformed++; continue; } const id = String(rawId).trim(); if (seen.has(id)) { duplicateCount++; continue; } seen.add(id); const format = canonicalFormat(work.format); const rawFormat = stringValue(work.format).trim(); const statusClass = classifyStatus(work.status); if (format) { byFormat[format]++; if (statusClass === 'watched') watchedByFormat[format]++; } else { const label = rawFormat ? rawFormat.slice(0, 60) : 'Unknown format'; otherFormats.set(label, (otherFormats.get(label) || 0) + 1); } if (statusClass === 'watched') watchedCount++; else if (statusClass === 'blank') blankStatusCount++; else if (statusClass === 'unknown') unknownStatusCount++; works.push({ id, format: format || rawFormat || 'Unknown format', status: work.status ?? null }); } const savedCount = works.length; const errorValue = input.error; const errorMessage = typeof errorValue === 'string' ? errorValue : stringValue(errorValue?.message); const state = errorValue || input.view === 'error' ? 'error' : input.view && input.view !== 'universe' ? 'loading' : !hasWorks ? 'error' : input.stale === true ? 'stale' : savedCount === 0 ? 'empty' : 'ready'; const updatedLabel = formatTime(input.updatedAt); const countsKnown = state !== 'error' && state !== 'loading'; let message = ''; if (state === 'error') message = errorMessage ? `Your universe could not be refreshed. ${errorMessage}` : 'Your universe could not be refreshed.'; else if (state === 'loading') message = 'Preparing your universe…'; else if (state === 'stale') message = updatedLabel ? `Showing your saved snapshot · Updated ${updatedLabel}` : 'Showing your saved snapshot · A fresh view is not available yet.'; else if (state === 'empty') message = 'No saved works yet. Your universe starts with the first one.'; else if (input.cached === true) message = updatedLabel ? `Saved snapshot · Updated ${updatedLabel}` : 'Showing your saved snapshot.'; else message = 'Every saved work has a place among the stars.'; if ((state === 'ready' || state === 'stale') && (unknownStatusCount || blankStatusCount)) { const notes = []; if (unknownStatusCount) notes.push(`${unknownStatusCount} ${unknownStatusCount === 1 ? 'work has' : 'works have'} an unrecognized watch status`); if (blankStatusCount) notes.push(`${blankStatusCount} ${blankStatusCount === 1 ? 'work has' : 'works have'} no watch status recorded`); message = input.cached === true || input.stale === true ? `${message} · ${notes.join('; ')}.` : `${notes.join('; ')}.`; } return { state, message, errorMessage, works, savedCount, watchedCount, countsKnown, formatCounts: byFormat, formatWatchedCounts: watchedByFormat, otherFormats: [...otherFormats.entries()].map(([format, count]) => ({ format, count })), otherCount: [...otherFormats.values()].reduce((sum, count) => sum + count, 0), unknownStatusCount, blankStatusCount, malformedCount: malformed, duplicateCount, updatedAt: input.updatedAt ?? null, updatedLabel, cached: input.cached === true, stale: input.stale === true, revision: input.revision ?? null, }; } function createElement(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; if (text != null) element.textContent = String(text); return element; } function makeFormatButton(format, onOpenFormat) { const button = createElement('button', `universe-format-star universe-format-${format === 'Anime Movie' ? 'anime-movie' : format.toLowerCase()}`); button.type = 'button'; button.dataset.universeFormat = format; button.setAttribute('aria-label', `${format} counts are unavailable while your universe loads.`); button.disabled = true; const glint = createElement('span', 'universe-format-glint', '✦'); glint.setAttribute('aria-hidden', 'true'); const title = createElement('span', 'universe-format-title', format); const count = createElement('span', 'universe-format-count', '— saved · — watched'); button.append(glint, title, count); button.addEventListener('click', () => onOpenFormat?.(format)); return { button, count }; } function drawCanvas(canvas, model, activeFormat = '') { const bounds = canvas.getBoundingClientRect(); const width = Math.max(1, bounds.width); const height = Math.max(1, bounds.height); const ratio = clamp(Number(globalThis.devicePixelRatio) || 1, 1, 2); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const ctx = canvas.getContext?.('2d'); if (!ctx) return; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, width, height); // A low contrast nebula grows gently with the saved library. Work-star // coordinates below are independent of this spread, so additions never move old stars. const spread = clamp(0.54 + Math.log2(model.savedCount + 1) * 0.045, 0.54, 0.86); canvas.parentElement?.style.setProperty('--universe-nebula-spread', String(spread)); const haze = ctx.createRadialGradient(width * 0.5, height * 0.5, 0, width * 0.5, height * 0.5, Math.max(width, height) * spread); haze.addColorStop(0, 'rgba(101, 126, 174, 0.105)'); haze.addColorStop(0.52, 'rgba(70, 94, 146, 0.052)'); haze.addColorStop(1, 'rgba(70, 94, 146, 0)'); ctx.fillStyle = haze; ctx.fillRect(0, 0, width, height); // Each format gets its own soft cloud. Saved count changes only the halo // radius; the ID-based work-star positions remain fixed as the library grows. for (const format of FORMAT_ORDER) { const [centerX, centerY] = FORMAT_ANCHORS[format]; const count = model.formatCounts[format] || 0; const radiusScale = clamp(0.12 + Math.log2(count + 1) * 0.03, 0.12, 0.36); const radius = Math.min(width, height) * radiusScale; const active = activeFormat === format; const opacity = activeFormat ? (active ? 0.12 : 0.009) : (count ? 0.047 : 0.019); const cloud = ctx.createRadialGradient(centerX * width, centerY * height, 0, centerX * width, centerY * height, radius); cloud.addColorStop(0, rgba(FORMAT_COLORS[format], opacity)); cloud.addColorStop(0.46, rgba(FORMAT_COLORS[format], opacity * 0.42)); cloud.addColorStop(1, rgba(FORMAT_COLORS[format], 0)); ctx.fillStyle = cloud; ctx.fillRect(0, 0, width, height); } // Decorative stars are generated separately and never included in WORK STAR counts. ctx.fillStyle = 'rgba(221, 233, 255, 0.44)'; for (let index = 0; index < 92; index++) { const x = unitHash(`decorative-x-${index}`) * width; const y = unitHash(`decorative-y-${index}`) * height; const radius = 0.35 + unitHash(`decorative-r-${index}`) * 0.85; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } for (const work of model.works) { const position = workStarPosition(work.id, work.format); const x = position.x * width; const y = position.y * height; const format = canonicalFormat(work.format); const color = FORMAT_COLORS[format] || '#a9b9d9'; ctx.fillStyle = color; const isActive = activeFormat && format === activeFormat; ctx.globalAlpha = activeFormat ? (isActive ? 1 : 0.12) : 0.78; ctx.shadowColor = isActive ? color : 'transparent'; ctx.shadowBlur = isActive ? 8 : 0; const radius = 1.15 + unitHash(`${work.id}:size`) * 1.25; ctx.beginPath(); ctx.arc(x, y, isActive ? radius * 1.12 : radius, 0, Math.PI * 2); ctx.fill(); } ctx.shadowBlur = 0; ctx.globalAlpha = 1; } /** Create the public Home view. The module owns only the supplied container. */ export function createUniverseHome({ container, onOpenFormat, onWorlds, onDiscover, onOtherFormats } = {}) { if (!container?.replaceChildren) throw new TypeError('createUniverseHome requires a DOM container'); const root = createElement('section', 'universe-home'); root.setAttribute('aria-label', 'Your Media Orbit universe'); const scene = createElement('div', 'universe-scene'); const canvas = createElement('canvas', 'universe-canvas'); canvas.setAttribute('aria-hidden', 'true'); canvas.dataset.workStars = '0'; canvas.dataset.decorativeStars = '92'; const heading = createElement('header', 'universe-heading'); const eyebrow = createElement('p', 'universe-eyebrow', 'MEDIA ORBIT'); const title = createElement('h1', 'universe-title', 'YOUR UNIVERSE'); const total = createElement('p', 'universe-total'); const totalNumber = createElement('strong', '', '—'); const totalLabel = createElement('span', '', 'SAVED WORKS'); total.append(totalNumber, totalLabel); const watched = createElement('p', 'universe-watched', '— WATCHED'); const status = createElement('p', 'universe-status'); status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); heading.append(eyebrow, title, total, watched, status); const formatNav = createElement('nav', 'universe-format-orbits'); formatNav.setAttribute('aria-label', 'Open saved works by format'); const formatControls = new Map(); for (const format of FORMAT_ORDER) { const control = makeFormatButton(format, onOpenFormat); formatControls.set(format, control); formatNav.append(control.button); } const statusMeta = createElement('p', 'universe-meta'); const otherButton = createElement('button', 'universe-other-formats', 'OTHER FORMATS · 0'); otherButton.type = 'button'; otherButton.disabled = true; otherButton.setAttribute('aria-label', 'Other format counts are unavailable while your universe loads.'); otherButton.addEventListener('click', () => onOtherFormats?.(currentModel.otherFormats)); scene.append(canvas, heading, formatNav); const footer = createElement('footer', 'universe-footer'); const footerNote = createElement('p', 'universe-footer-note', 'Your saved stories, gathered in one sky.'); const footerActions = createElement('div', 'universe-footer-actions'); const worldsButton = createElement('button', 'universe-footer-button universe-worlds-button', 'MY WORLDS'); worldsButton.type = 'button'; worldsButton.addEventListener('click', () => onWorlds?.()); const discoverButton = createElement('button', 'universe-footer-button', 'DISCOVER'); discoverButton.type = 'button'; discoverButton.addEventListener('click', () => onDiscover?.()); footerActions.append(otherButton, discoverButton, worldsButton); footer.append(footerNote, footerActions, statusMeta); root.append(scene, footer); container.replaceChildren(root); let currentModel = buildUniverseModel({ view: 'loading', works: null }); let disposed = false; let resizeObserver = null; let hoveredFormat = ''; let focusedFormat = ''; function isSceneVisible() { if (!root.isConnected) return false; if (typeof scene.checkVisibility === 'function' && !scene.checkVisibility()) return false; const bounds = scene.getBoundingClientRect(); return bounds.width > 0 && bounds.height > 0; } const drawVisible = () => { if (!disposed && isSceneVisible()) drawCanvas(canvas, currentModel, hoveredFormat || focusedFormat); }; const drawWhenResized = () => { if (!disposed) drawVisible(); }; root.dataset.state = currentModel.state; root.dataset.cached = 'false'; for (const [format, control] of formatControls) { control.button.addEventListener('pointerenter', () => { if (control.button.disabled || hoveredFormat === format) return; hoveredFormat = format; drawVisible(); }); control.button.addEventListener('pointerleave', () => { if (hoveredFormat !== format) return; hoveredFormat = ''; drawVisible(); }); control.button.addEventListener('focus', () => { if (control.button.disabled || focusedFormat === format) return; focusedFormat = format; drawVisible(); }); control.button.addEventListener('blur', () => { if (focusedFormat !== format) return; focusedFormat = ''; drawVisible(); }); } if (typeof globalThis.ResizeObserver === 'function') { resizeObserver = new ResizeObserver(drawWhenResized); resizeObserver.observe(scene); } else globalThis.addEventListener?.('resize', drawWhenResized); function update(payload) { if (disposed) return currentModel; currentModel = buildUniverseModel(payload); if (!currentModel.countsKnown) { hoveredFormat = ''; focusedFormat = ''; } root.dataset.state = currentModel.state; root.dataset.cached = String(currentModel.cached); totalNumber.textContent = currentModel.countsKnown ? String(currentModel.savedCount) : '—'; watched.textContent = currentModel.countsKnown ? `${currentModel.watchedCount} WATCHED` : '— WATCHED'; status.textContent = currentModel.message; status.dataset.state = currentModel.state; statusMeta.textContent = currentModel.updatedLabel && currentModel.state !== 'stale' ? `LAST UPDATED · ${currentModel.updatedLabel}` : ''; otherButton.textContent = `OTHER FORMATS · ${currentModel.countsKnown ? currentModel.otherCount : '—'}`; otherButton.hidden = currentModel.otherCount === 0; otherButton.disabled = !currentModel.countsKnown; const unavailableReason = currentModel.state === 'loading' ? 'while your universe loads' : 'because your universe could not be refreshed'; otherButton.setAttribute('aria-label', currentModel.countsKnown ? `Other formats, ${currentModel.otherCount} saved works. Open other formats` : `Other format counts are unavailable ${unavailableReason}.`); for (const [format, control] of formatControls) { const count = currentModel.formatCounts[format] || 0; const watchedCount = currentModel.formatWatchedCounts[format] || 0; control.button.disabled = !currentModel.countsKnown; control.count.textContent = currentModel.countsKnown ? `${count} saved · ${watchedCount} watched` : '— saved · — watched'; control.button.setAttribute('aria-label', currentModel.countsKnown ? `${format}, ${count} saved ${count === 1 ? 'work' : 'works'}, ${watchedCount} watched. Open format.` : `${format} counts are unavailable ${unavailableReason}.`); } canvas.dataset.workStars = String(currentModel.savedCount); canvas.dataset.revision = currentModel.revision == null ? '' : String(currentModel.revision); drawVisible(); return currentModel; } function dispose() { if (disposed) return; disposed = true; resizeObserver?.disconnect(); if (!resizeObserver) globalThis.removeEventListener?.('resize', drawWhenResized); if (root.parentNode === container) container.replaceChildren(); } return { update, dispose }; }