import {loadLibrary} from './sync.js?v=universe-home-1'; import {compactUniversePayload,formatWorkIds} from './orbit-route.js?v=universe-home-1'; import {buildOrbitGraph,searchOrbitGraph} from './orbit-graph.js?v=page-identity-1'; import {graphBounds} from './orbit-force.js?v=poster-first-1'; import {createD3PosterSimulation,settleD3Rectangles} from './orbit-d3-layout.js?v=d3-force-1'; import {buildPosterUniverse,layoutPosterUniverse,reusePosterLayout} from './orbit-universe.js?v=orbit-cleanup-1'; import {buildPeopleIndex,layoutPeopleNodes} from './orbit-people.js?v=world-exploration-3'; import {SORTS,ROLES,sortedWorkIds,movieRegion,creditSections,matchCreditToLibrary,matchesFormat} from './orbit-explore.js?v=world-exploration-3'; import {createPeopleControlsState,serializePeopleControlsState,setPeopleRoleEnabled,selectPerson,hidePerson,showPerson,togglePinnedPerson,filterPeople,filterPersonFilmography,latestPersonCreditInScope} from './orbit-people-controls.js?v=world-exploration-3'; import {createPeopleSync} from './orbit-people-sync.js?v=world-exploration-3'; import {selectionPullTargets} from './orbit-focus.js?v=constellation-1'; import {createFrameQueue,createIdleRenderTask,setAttributeIfChanged,cameraViewport,cameraCullMargin,cameraTransform,cameraTransformNeedsCommit,intersectsViewport,nodeInCameraRegion,posterTierForUpdate,selectHighResolutionPosters,workFrameWidth as focusedFrameWidth,zoomCamera} from './orbit-render.js?v=zoom-camera-4'; import {createWorldsController,savedWorldWorkIndex,worldWorkKey,NETFLIX_WORLD} from './orbit-worlds.js?v=world-netflix-1'; import {createWorldView} from './orbit-world-view.js?v=world-netflix-1'; import {normalizePageId,indexWorksByPageId,workForPageId,posterClipId,keepDetailWhileFocusingSavedWork} from './orbit-library-identity.js?v=page-identity-1'; const $=id=>document.getElementById(id),NS='http://www.w3.org/2000/svg'; const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const key=value=>String(value||'').normalize('NFKC').replace(/﨑/g,'崎').replace(/髙/g,'高').toLowerCase().replace(/[\s\p{P}]/gu,''); const safeURL=value=>{try{const u=new URL(value);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password?u.href:'';}catch{return '';}}; const picture=(data,index)=>{const value=data.i?.[index];return typeof value==='string'&&(/^(?:\/posters\/[a-f0-9]+\.webp)$/.test(value)||/^data:image\/(?:webp|png|jpeg);base64,[A-Za-z0-9+/=]+$/.test(value))?value:safeURL(value);}; const shortLabel=(label,width)=>{let used=0,shown='';for(const char of String(label)){const size=char.codePointAt(0)>255?11:6;if(used+size>width-10)return shown+'…';shown+=char;used+=size;}return shown;}; const mapPoster=(url,large=false)=>url.replace(/^https:\/\/image\.tmdb\.org\/t\/p\/(?:w\d+|original)\//,'https://image.tmdb.org/t/p/'+(large?'w500/':'w185/')); const native=id=>'https://app.notion.com/p/'+id; const pageId=normalizePageId; const labelKinds={person:'PERSON',root:'UNIVERSE',format:'FORMAT',world:'WORLD',series:'SERIES',director:'DIRECTOR',author:'AUTHOR',work:'WORK'}; const colors={'Movie':'#7dcbd7','Anime':'#b6a0e3','Anime Movie':'#e0a0ad','Drama':'#dfc48d','YouTube':'#8ecbb0'}; let currentFormat=null,appOptions={},libraryFlight=null,libraryController=null,fullUpdatedAt=0,libraryNeeded=false; const sceneSnapshots=new Map(); let DATA=null,graph=null,searchGraph=null,camera={x:0,y:0,width:4000},animation=null,simulation=null,layoutEngine=null,energy=0; let connections=true,drift=false,reduced=matchMedia('(prefers-reduced-motion: reduce)').matches,hoverId=null,activeId=null,drag=null,selectionBeforeDrag=null,hoverRefreshPending=false; let overview=true; let suggestions=[],suggestionIndex=0,searchTimer=null,trail=[],toastTimer=null; const filters={world:new Set(),format:new Set(),region:new Set(),people:new Set(),decade:new Set(),status:new Set()}; let peopleIndex=null,focusedPersonId=null,sortMode='oldest',peopleControls=readPeopleControls(),connectionRoles=peopleControls.roles,peoplePaintTimer=null,peopleSyncReady=false; const resolvedPeople=readResolvedPeople(),peopleFlights=new Map(),dubbingCache=new Map(),filmographyFilters=new Map(); const peopleSync=createPeopleSync({load:id=>loadCast(id,false),isReady:id=>hasPeopleMetadata(id),onUpdate:()=>queuePeoplePaint(),onProgress:reportPeopleSync}); const membershipByWork=new Map(); let worksByPageId=new Map(); const elements=new Map(),edgeElements=[],castCache=new Map(),castFlights=new Map(),personCache=new Map(); const focusOrigins=new Map();let focusId=null; const solidNodes=()=>graph?.nodes.filter(n=>n.kind!=='format'); let fieldSize={left:0,top:0,width:1,height:1},interactionUntil=0,cameraNodes=[],cameraTransforming=false,committedCamera=null,committedSize=null,cameraCullInterval=null; const cameraFrames=createFrameQueue(()=>applyCamera()); const dragFrames=createFrameQueue(()=>paint()); const deferredCameraPaint=createIdleRenderTask(()=>{ if(!graph||currentFormat===null)return; const el=$('graph'); if(el.dataset.traveling==='true'){deferredCameraPaint.schedule();return;} cameraTransforming=false; stopCameraCull(); commitCameraView(); const scale=fieldSize.width/camera.width; const zoom=220*scale>=110?'detail':220*scale>=45?'neighborhood':'overview'; if(el.dataset.zoom!==zoom)el.dataset.zoom=zoom; delete el.dataset.interacting; updateVisiblePosters({settled:true}); if(hoverRefreshPending){hoverRefreshPending=false;highlight();} if(energy>0||drift&&!reduced)warm(Math.max(energy,1)); }); const confirmedSaves=new Map(),customMembers=new Map();let worldsFlight=null,worldsLoaded=false,worldListError='',worldOpenGeneration=0; const worldsController=createWorldsController({requestJSON,onChange:()=>{if(searchGraph){syncWorldMetadata();renderFilters();renderHomeWorlds();highlight();}}}); const worldView=createWorldView({controller:worldsController,getData:()=>DATA,getFixedWorlds:()=>worldNodes().filter(n=>!n.id.startsWith('cw_')),getCustomMemberIds:id=>customWorldIds(id),getSubseries:worldId=>worldSubseries(worldId),onOpenSeries:node=>void openWorld({...node,kind:"world",keywords:node.searchLabels?.[0]||node.label}),header:drawerHeader,onClose:closeDrawer,onBack:backDetail,resolveSavedWork:id=>loadCast(id,false),openSavedWork:id=>openSavedWork(id),onChanged:detail=>{if(detail?.kind==='world'){if(trail.at(-1)?.id===detail.id||trail.at(-1)?.kind==='world-create')trail[trail.length-1]=detail;if(detail.id.startsWith('cw_'))void hydrateCustomWorld(detail.id);}else if(detail?.deleted){closeDrawer();}if(searchGraph){syncWorldMetadata();renderFilters();renderHomeWorlds();highlight();}}}); function workForNode(node){return workForPageId(worksByPageId,node?.pageId??node?.workIds?.[0]??node?.id?.replace(/^work:/,''));} function worldSubseries(worldId){return (searchGraph?.nodes||[]).filter(n=>['series','world'].includes(n.kind)&&n.parentId===worldId).sort((a,b)=>a.label.localeCompare(b.label,'ja'));} function worldNodes(){return searchGraph?.nodes.filter(n=>n.kind==='world')||[];} function customWorldIds(id){const exact=savedWorldWorkIndex(DATA?.w,DATA?.workDetails);return [...new Set((customMembers.get(id)||[]).map(member=>exact.get(worldWorkKey(member))).filter(Boolean))];} function syncWorldMetadata(){ if(!searchGraph)return; for(const n of searchGraph.nodes.filter(n=>n.kind==='world'&&!n.id.startsWith('cw_'))){const raw=DATA?.worlds?.find(w=>'world:'+w.id===n.id);n.keywords=raw?.label||n.label;} const existing=searchGraph.nodes.filter(n=>n.id.startsWith('cw_'));for(const n of existing)searchGraph.byId.delete(n.id);searchGraph.nodes=searchGraph.nodes.filter(n=>!n.id.startsWith('cw_')); for(const world of DATA?.worlds||[]){const id='world:'+world.id;if(world.id==='ALL'||searchGraph.byId.has(id))continue;const n={id,kind:'world',label:world.mapLabel||world.label,keywords:world.label,color:'#81c7b0',workIds:DATA.w.filter(w=>w[5]===world.id).map(w=>w[0]),searchLabels:[world.label,world.mapLabel].filter(Boolean)};searchGraph.nodes.push(n);searchGraph.byId.set(id,n);} if(!searchGraph.byId.has('world:pirates')){const n={id:'world:pirates',kind:'world',label:'PIRATES',keywords:'パイレーツ・オブ・カリビアン',color:'#81c7b0',workIds:DATA.w.filter(w=>/^(?:パイレーツオブカリビアン|piratesofthecaribbean)/.test(key(w[1]))).map(w=>w[0]),searchLabels:['PIRATES','パイレーツ・オブ・カリビアン']};searchGraph.nodes.push(n);searchGraph.byId.set(n.id,n);} if(!searchGraph.byId.has('world:doraemon')){const n={id:'world:doraemon',kind:'world',label:'DORAEMON',keywords:'ドラえもん',color:'#81c7b0',workIds:[],searchLabels:['ドラえもん','DORAEMON']};searchGraph.nodes.push(n);searchGraph.byId.set(n.id,n);} if(!searchGraph.byId.has(NETFLIX_WORLD.id)){const n={...NETFLIX_WORLD,kind:'world',workIds:DATA.w.filter(w=>w[5]==='netflix').map(w=>w[0])};searchGraph.nodes.push(n);searchGraph.byId.set(n.id,n);} for(const world of worldsController.worlds){const n={...world,kind:'world',label:world.name,color:'#81c7b0',workIds:customWorldIds(world.id),searchLabels:[world.name,world.keywords]};searchGraph.nodes.push(n);searchGraph.byId.set(n.id,n);} membershipByWork.clear();for(const n of searchGraph.nodes)for(const id of n.workIds||[]){if(!membershipByWork.has(id))membershipByWork.set(id,[]);membershipByWork.get(id).push(n);}for(const id of filters.world)if(!searchGraph.byId.has(id))filters.world.delete(id); } async function loadWorldList(){if(worldsLoaded)return;if(worldsFlight)return worldsFlight;worldsFlight=worldsController.list().then(()=>{worldsLoaded=true;worldListError='';}).catch(error=>{worldListError=error.message;}).finally(()=>{worldsFlight=null;renderFilters();renderHomeWorlds();});return worldsFlight;} async function hydrateCustomWorld(id){try{let result=await worldsController.detail(id);while(result.membersHasMore)result=await worldsController.moreMembers(id);customMembers.set(id,result.members||[]);syncWorldMetadata();renderFilters();highlight();return result;}catch(error){toast(error.message);}} async function openWorld(node){const generation=++worldOpenGeneration;if(node.id.startsWith('cw_'))await hydrateCustomWorld(node.id);if(generation!==worldOpenGeneration)return;const current=searchGraph.byId.get(node.id)||node;activeId=current.id;openDetail({...current,kind:'world'});if(graph&¤t.workIds.length)travel(current,true);} function openCreateWorld(){worldOpenGeneration++;setFilterOpen(false);setConnectionsOpen(false);worldView.deactivate?.();trail=[{kind:'world-create',id:'world-create'}];activeId=null;focusedPersonId=null;$('drawer').hidden=false;$('workspace').classList.add('has-detail');worldView.openCreate();highlight();applyCamera();} function measureField(){const field=$('field'),r=field.getBoundingClientRect();fieldSize={left:r.left+field.clientLeft,top:r.top+field.clientTop,width:Math.max(1,field.clientWidth),height:Math.max(1,field.clientHeight)};return fieldSize;} function stopCameraCull(){if(cameraCullInterval!==null){clearInterval(cameraCullInterval);cameraCullInterval=null;}} function hoverSuppressed(){return cameraTransforming||Boolean(drag?.moved)||selectionBeforeDrag||performance.now(){if(cameraTransforming)updateVisiblePosters({settled:false});else stopCameraCull();},180);} function requestCamera(){startCameraTransform();interactionUntil=performance.now()+160;$('graph').dataset.interacting='true';cameraFrames.request();} function deferPosterPaint(){deferredCameraPaint.schedule();} function updateVisiblePosters({settled=false}={}){ if(!graph)return; const margin=cameraCullMargin(camera,fieldSize,cameraTransforming),viewport=cameraViewport(camera,fieldSize,margin),visibleViewport=cameraViewport(camera,fieldSize,0),scale=fieldSize.width/camera.width; const selectedNode=graph.byId.get(activeId),selectedId=selectedNode?.kind==='work'?selectedNode.id:null; const highResolution=settled?selectHighResolutionPosters(graph.nodes.filter(n=>n.kind==='work'&&n.imageElement&&n.posterURL).map(n=>({id:n.id,currentTier:n.posterTier||0,screenWidth:n.posterWidth*scale,visible:n.id===selectedId||intersectsViewport(n,visibleViewport)})),{selectedId}):new Set(); for(const n of graph.nodes){ const el=elements.get(n.id);if(!el)continue; const visible=nodeInCameraRegion(n,viewport,selectedId),isOutOfView=el.classList.contains('out-of-view'); if(isOutOfView!==!visible)el.classList.toggle('out-of-view',!visible); if(n.kind==='work'&&n.imageElement&&n.posterURL){ const tier=posterTierForUpdate(n.posterTier,{inRange:visible,settled,highResolution:highResolution.has(n.id)}); if(tier!==n.posterTier){n.posterTier=tier;setAttributeIfChanged(n.imageElement,'href',mapPoster(n.posterURL,tier===2));} } } } function updateCameraNodeScales(){const scale=fieldSize.width/camera.width;for(const n of cameraNodes){const factor=n.kind==='root'?Math.max(1,Math.min(6,105/(170*scale))):Math.max(1,Math.min(6,10/(14*scale)));setAttributeIfChanged(n.bodyElement,'transform',`scale(${factor})`);}} function commitCameraView({keepTransform=false}={}){const el=$('graph'),r=fieldSize;setAttributeIfChanged(el,'viewBox',`${camera.x-camera.width/2} ${camera.y-camera.width*r.height/r.width/2} ${camera.width} ${camera.width*r.height/r.width}`);el.style.transform='';if(!keepTransform)el.classList.remove('camera-transforming');committedCamera={...camera};committedSize={width:r.width,height:r.height};cameraTransforming=keepTransform;updateCameraNodeScales();} function svg(tag,attrs={},content='') {const el=document.createElementNS(NS,tag);for(const [name,value]of Object.entries(attrs))el.setAttribute(name,String(value));if(content)el.textContent=content;return el;} function toast(message){$('toast').textContent=message;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').hidden=true,3500);} function readPeopleControls(){try{return createPeopleControlsState(JSON.parse(localStorage.getItem('mediaorbit.people-controls')||'{}'));}catch{return createPeopleControlsState();}} function readResolvedPeople(){try{return new Map(JSON.parse(localStorage.getItem('mediaorbit.people-identities')||'[]').filter(row=>Array.isArray(row)&&typeof row[0]==='string'&&row[0].startsWith('person:pending:')&&Number.isSafeInteger(row[1]?.id)&&row[1].id>0&&typeof row[1]?.name==='string').slice(0,500));}catch{return new Map();}} function persistResolvedPeople(){try{localStorage.setItem('mediaorbit.people-identities',JSON.stringify([...resolvedPeople].map(([id,p])=>[id,{id:p.id,name:p.name,originalName:p.originalName,profile:p.profile}])));}catch{}} function reindexPeople(){const previous=peopleIndex;peopleIndex=buildPeopleIndex(DATA,searchGraph);applyResolvedPeople();if(previous)for(const old of previous.people){if(peopleIndex.byId.has(old.id))continue;const names=new Set([old.name,...old.aliases].map(key)),candidates=peopleIndex.people.filter(person=>person.workIds.some(id=>old.workIds.includes(id))&&[person.name,...person.aliases].some(name=>names.has(key(name))));if(candidates.length===1)remapPersonId(old.id,candidates[0].id);}} function persistPeopleControls(){try{localStorage.setItem('mediaorbit.people-controls',JSON.stringify(serializePeopleControlsState(peopleControls)));}catch{/* Storage restrictions must not block the map. */}} function updatePeopleControls(next){peopleControls=next;connectionRoles=next.roles;persistPeopleControls();refreshPeopleLayer({reindex:false});renderConnectionOptions();if(overview)fit();} function remapPersonId(from,to){if(from===to)return;for(const set of [peopleControls.selectedPeopleIds,peopleControls.pinnedPeopleIds,peopleControls.hiddenPeopleIds,filters.people])if(set.delete(from))set.add(to);if(filmographyFilters.has(from)){filmographyFilters.set(to,filmographyFilters.get(from));filmographyFilters.delete(from);}if(focusedPersonId===from)focusedPersonId=to;if(activeId===from)activeId=to;for(const detail of trail)if(detail.kind==='person'&&detail.id===from)detail.id=to;persistPeopleControls();} function hasPeopleMetadata(id){const info=DATA?.workDetails?.[id];return castCache.get(id)?.status==='ready'||Boolean(info&&['cast','directors','authors','writers'].every(field=>Array.isArray(info[field])));} function queuePeoplePaint(){if(peoplePaintTimer)return;peoplePaintTimer=setTimeout(()=>{peoplePaintTimer=null;if(!graph)return;if(performance.now()graph.byId.get('work:'+id)).filter(Boolean);const cohort=target.kind==='person'?works.sort((a,b)=>Math.hypot(a.x-target.x,a.y-target.y)-Math.hypot(b.x-target.x,b.y-target.y)).slice(0,4):works; const nodes=target.kind==='work'?[target]:[...(target.kind==='person'?[target]:[]),...cohort]; const bounds=graphBounds(nodes.length?nodes:solidNodes(),full?110:50),r=measureField(); const width=full?(target.kind==='work'?workFrameWidth(target):Math.max(820,bounds.width,bounds.height*r.width/Math.max(1,r.height))):camera.width; const to={x:target.kind==='person'?target.x:bounds.x,y:target.kind==='person'?target.y:bounds.y,width:full&&target.kind==='person'?Math.min(3800,Math.max(1100,width)):width},from={...camera},start=performance.now(),duration=reduced?0:full?650:300; function frame(now){if(!graph||currentFormat===null)return;const t=duration?Math.min(1,(now-start)/duration):1,ease=1-Math.pow(1-t,3);for(const p of ['x','y','width'])camera[p]=from[p]+(to[p]-from[p])*ease;applyCamera();if(t<1)animation=requestAnimationFrame(frame);else delete $('graph').dataset.traveling;} animation=requestAnimationFrame(frame); } function fit(){if(!graph?.nodes.length)return;overview=true;cameraFrames.cancel();cancelAnimationFrame(animation);delete $('graph').dataset.traveling;delete $('graph').dataset.interacting;stopCameraCull();cameraTransforming=false;const r=measureField(),b=graphBounds(solidNodes(),170);camera={x:b.x,y:b.y,width:Math.max(b.width,b.height*r.width/Math.max(1,r.height))};applyCamera();} function rebuild({preserve=true}={}){ if(!DATA?.w||currentFormat===null)return; const started=performance.now();cancelSceneJobs(); cancelAnimationFrame(simulation);simulation=null;layoutEngine?.stop();layoutEngine=null;energy=0;restoreFocusAnchors();focusId=null;const prior=graph?.byId||sceneSnapshots.get(currentFormat)?.nodes;graph=buildPosterUniverse(searchGraph,{workIds:formatWorkIds(DATA,currentFormat),includeRoot:false}); membershipByWork.clear();for(const n of searchGraph.nodes)for(const id of n.workIds){if(!membershipByWork.has(id))membershipByWork.set(id,[]);membershipByWork.get(id).push(n);} for(const n of graph.nodes){ if(n.kind==='root'){n.width=2000;n.height=1500;} else if(n.kind==='work'){ const memberships=membershipByWork.get(n.workIds[0])||[],related=Math.max(1,...memberships.filter(p=>p.kind==='director'||p.kind==='author').map(p=>p.workIds.length)); const old=preserve&&prior?.get(n.id),work=workForNode(n); n.posterWidth=old?.posterWidth||Math.min(236,210+Math.log2(related+1)*5);n.posterHeight=old?.posterHeight||n.posterWidth*1.5; n.width=old?.width||n.posterWidth+24;n.height=old?.height||n.posterHeight+68;n.color=colors[DATA.f[work?.[2]]]||'#9baeb9'; } } const reuseLayout=preserve&&prior&&reusePosterLayout(graph,prior); if(!reuseLayout)layoutPosterUniverse(graph); const positions={'Movie':[-580,-360],'Anime':[580,-360],'Anime Movie':[-580,360],'Drama':[580,360]}; for(const source of searchGraph.nodes.filter(n=>n.kind==='format'&&n.label===currentFormat)){const [x,y]=positions[source.label]||[0,-360],n={...source,workIds:[...source.workIds],x,y,ax:x,ay:y,width:128,height:38,color:colors[source.label]};graph.nodes.push(n);graph.byId.set(n.id,n);} if(preserve&&prior)for(const n of graph.nodes){const old=prior.get(n.id);if(old){n.x=old.x;n.y=old.y;n.rotation=old.rotation;}} for(const n of graph.nodes){n.ax=n.x;n.ay=n.y;} for(const edge of graph.links){const a=graph.byId.get(edge.source),b=graph.byId.get(edge.target);if(a&&b)edge.restLength=Math.hypot(b.x-a.x,b.y-a.y);} for(const n of solidNodes()){n.ax=n.x;n.ay=n.y;} for(const edge of graph.links){const a=graph.byId.get(edge.source),b=graph.byId.get(edge.target);if(a&&b)edge.restLength=Math.hypot(b.x-a.x,b.y-a.y);} const peopleLayout=peopleLayoutFor();for(const n of peopleLayout.nodes){n.ax=n.x;n.ay=n.y;graph.nodes.push(n);graph.byId.set(n.id,n);}graph.personLinks=peopleLayout.links;reportPeopleLayout(peopleLayout); elements.clear();edgeElements.length=0;$('links').replaceChildren();$('nodes').replaceChildren();drawConstellations();drawPeopleLinks();drawOrbits(); for(const n of graph.nodes){const el=svg('g',{class:'orbit-node '+n.kind,tabindex:0,role:'button','aria-label':`${n.label} · ${labelKinds[n.kind]}`,'data-node-id':n.id});el.append(svg('title',{},n.label));const body=svg('g',{class:'node-body'});n.bodyElement=body;el.append(body); if(n.kind==='root'){body.append(svg('circle',{r:150,fill:'url(#rootGlow)'}),svg('circle',{r:61,fill:'#17252b',stroke:'#79b3ad',class:'node-border'}),svg('ellipse',{rx:78,ry:24,fill:'none',stroke:'#a7dbce','stroke-opacity':.45,transform:'rotate(-32)'}),svg('text',{y:-4,class:'root-label'},'MEDIA'),svg('text',{y:19,class:'root-label'},'ORBIT'),svg('text',{y:37,class:'root-sub'},'YOUR UNIVERSE'));} else if(n.kind==='format'){body.append(svg('rect',{x:-78,y:-24,width:156,height:48,fill:'transparent','pointer-events':'all'}),svg('circle',{cx:-51,cy:0,r:3,fill:n.color}),svg('text',{x:7,y:5,class:'format-label',fill:n.color},n.label));} else if(n.kind==='person'){drawPersonBody(body,n);} else if(n.kind==='work'){ const work=workForNode(n),url=picture(DATA,work?.[7]),w=n.posterWidth,h=n.posterHeight; body.append(svg('rect',{x:-w/2,y:-h/2,width:w,height:h,rx:9,fill:'#23313b',stroke:n.color,class:'node-border poster-border'})); n.posterURL=url; if(url){const clipId=posterClipId(n.pageId||n.workIds[0]),clip=svg('clipPath',{id:clipId});clip.append(svg('rect',{x:-w/2+1,y:-h/2+1,width:w-2,height:h-2,rx:8}));n.posterTier=0;n.imageElement=svg('image',{x:-w/2+1,y:-h/2+1,width:w-2,height:h-2,preserveAspectRatio:'xMidYMid slice','clip-path':`url(#${clipId})`});body.append(clip,n.imageElement);} else {body.append(svg('text',{class:'node-label poster-fallback',y:0},'▣'),svg('text',{class:'node-label',y:32},shortLabel(n.label,w)));} body.append(svg('text',{class:'node-label work-label',y:h/2+23},shortLabel(n.label,w)),svg('text',{class:'node-label work-year',y:h/2+42},work[3]||'')); } el.addEventListener('pointerenter',()=>{if(hoverSuppressed())return;hoverId=n.id;highlight();showPosterHint(n);});el.addEventListener('pointerleave',()=>{if(hoverSuppressed())return;hoverId=null;highlight();hidePosterHint();});el.addEventListener('focus',()=>{hoverId=n.id;highlight();showPosterHint(n);});el.addEventListener('blur',()=>{hoverId=null;highlight();hidePosterHint();});el.addEventListener('click',()=>{if(selectionBeforeDrag)return;hidePosterHint();selectNode(n);});el.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();selectNode(n);}});elements.set(n.id,el);$('nodes').append(el); } cameraNodes=graph.nodes.filter(n=>n.kind==='root'||n.kind==='format');$('workCount').textContent=DATA.w.length+' SAVED';renderFilters();paint();highlight();if(!preserve||!prior)fit();else{measureField();applyCamera();}updateVisiblePosters();$('field').dataset.workCount=String(graph.nodes.filter(n=>n.kind==='work').length);if(document.body.dataset.view==='orbit')$('sceneStatus').textContent=$('field').dataset.workCount+' SAVED WORKS';$('field').dataset.layoutMs=(performance.now()-started).toFixed(1);if(document.activeElement===$('search')&&$('search').value.trim())search(); } function paint(now=0){ if(!graph||currentFormat===null)return; for(const n of graph.nodes){ const offset=drift&&!reduced&&!cameraTransforming&&!drag?.moved&&n.kind==='work'?Math.sin(now*.00035+n.x*.03)*3:0; setAttributeIfChanged(elements.get(n.id),'transform',`translate(${n.x} ${n.y+offset})${n.kind==='work'?` rotate(${n.rotation||0})`:''}`); } for(const {edge,el}of edgeElements){const a=graph.byId.get(edge.source),b=graph.byId.get(edge.target);if(!a||!b)continue;const dx=b.x-a.x,dy=b.y-a.y;setAttributeIfChanged(el,'d',`M${a.x},${a.y} Q${a.x+dx*.5-dy*.06},${a.y+dy*.5+dx*.06} ${b.x},${b.y}`);} } function warm(frames=30){ if(!graph||currentFormat===null)return; energy=Math.max(energy,frames); // D3's own timer remains stopped. Camera movement gets exclusive frame time. if(simulation!==null){cancelAnimationFrame(simulation);simulation=null;} layoutEngine?.stop(); const fixedIds=new Set(focusId?[focusId]:[]),nodes=solidNodes(); if(focusOrigins.size)for(const node of nodes)if(node.kind==='work'&&!focusOrigins.has(node.id))fixedIds.add(node.id); layoutEngine=createD3PosterSimulation(nodes,graph.links,{fixedIds,charge:0,anchorStrength:.08,linkStrength:.02,alpha:.35,alphaDecay:.08}); if(cameraTransforming||drag?.moved)return; let last=0; function frame(now){ if(!graph||currentFormat===null||document.hidden||cameraTransforming||drag?.moved){simulation=null;return;} if(now-last>32){last=now;if(energy>0){layoutEngine.tick();energy--;if(energy===0){settleD3Rectangles(nodes,{fixedIds,gap:36,axisAligned:true});layoutEngine.stop();if(overview)fit();updateVisiblePosters();}}paint(now);} if(energy>0||(drift&&!reduced))simulation=requestAnimationFrame(frame);else simulation=null; } simulation=requestAnimationFrame(frame); } function drawOrbits(){ const layer=$('orbits');layer.replaceChildren(); for(const [rx,ry,angle]of [[1100,580,-22],[2250,1280,18],[3600,1720,-16]])layer.append(svg('ellipse',{rx,ry,transform:`rotate(${angle})`,class:'universe-orbit'})); } function drawConstellations(){ for(const edge of graph.constellationLinks||[]){ const strength=Math.max(0,Math.min(1,Number(edge.strength)||0)),el=svg('path',{class:'graph-link constellation',stroke:'#a8c2cf','data-source':edge.source,'data-target':edge.target,'data-strength':strength,style:`--line-opacity:${.04+strength*.10};--line-width:${.4+strength*.4}px;--hot-opacity:${.60+strength*.28}`}); $('links').append(el);edgeElements.push({edge,el}); } for(const n of graph.nodes.filter(n=>n.kind==='format')){const nearest=n.workIds.map(id=>graph.byId.get('work:'+id)).filter(Boolean).sort((a,b)=>Math.hypot(a.x-n.x,a.y-n.y)-Math.hypot(b.x-n.x,b.y-n.y)).slice(0,3);for(const work of nearest)addFormatLine(n.id,work.id);} } function addFormatLine(source,target,temporary=false){const edge={source,target,kind:'format'},el=svg('path',{class:'graph-link format-branch',stroke:graph.byId.get(source)?.color||'#9eccc7','data-source':source,'data-target':target});$('links').append(el);edgeElements.push({edge,el,temporary});} function restoreFocusAnchors(){ for(const [id,point]of focusOrigins){const node=graph?.byId.get(id);if(node){node.ax=point.x;node.ay=point.y;}} focusOrigins.clear(); } function updateSelectionPull(id){ if(id===focusId)return;const hadFocus=focusOrigins.size>0;restoreFocusAnchors();focusId=id; if(id&&!reduced)for(const [other,point]of selectionPullTargets(graph,id)){const node=graph.byId.get(other);focusOrigins.set(other,{x:node.ax??node.x,y:node.ay??node.y});node.ax=point.x;node.ay=point.y;} if(hadFocus||focusOrigins.size)warm(28); } function showPosterHint(n){ const hint=$('posterHint');if(n.kind!=='work'){hidePosterHint();return;} const w=workForNode(n);if(!w){hidePosterHint();return;}const directors=w[4].map(i=>DATA.d[i]).filter(Boolean).slice(0,2).join(' / '); hint.innerHTML=`${esc(n.label)}${esc([w[3],directors].filter(Boolean).join(' · '))}`;hint.hidden=false; } function hidePosterHint(){$('posterHint').hidden=true;} function matchingIds(){return new Set(DATA.w.filter(w=>{ const id=w[0],format=DATA.f[w[2]],members=membershipByWork.get(id)||[],info=DATA.workDetails?.[id]||{}; const people=peopleIndex?.byWork.get(id)||[],decade=w[3]?String(Math.floor(w[3]/10)*10)+'s':'Unknown'; return (currentFormat===null||format===currentFormat)&&(!filters.world.size||members.some(n=>n.kind==='world'&&filters.world.has(n.id)))&&matchesFormat(format,info,filters.format,filters.region)&&(!filters.people.size||people.some(n=>filters.people.has(n.id)))&&(!filters.decade.size||filters.decade.has(decade))&&(!filters.status.size||filters.status.has(w[6]===1?'視聴済み':w[6]===0?'未視聴':'未入力')); }).map(w=>w[0]));} function relatedWorkIds(id){ return new Set([...(membershipByWork.get(id)||[]).filter(n=>['series','world','director','author'].includes(n.kind)).flatMap(n=>n.workIds),...(DATA.workConnections?.[id]||[]),...(DATA.details?.[id]?.related||[])].filter(other=>other!==id&&worksByPageId.has(other))); } function highlight(){if(!graph)return; const activeNode=graph.byId.get(activeId)||searchGraph.byId.get(activeId),filtered=Object.values(filters).some(s=>s.size),matches=filtered?matchingIds():null; const compared=[...new Set([...peopleControls.selectedPeopleIds,...peopleControls.pinnedPeopleIds])].map(id=>peopleIndex?.byId.get(id)).filter(Boolean),comparison=!activeNode&&compared.length>0,related=new Set(activeNode?.workIds||(comparison?compared.flatMap(person=>person.workIds):[])); if(activeNode?.kind==='work')relatedWorkIds(activeNode.workIds[0]).forEach(id=>related.add(id)); for(const n of graph.nodes){const el=elements.get(n.id),near=n.kind==='root'||(!activeNode&&!comparison)||(n.kind==='person'?(activeNode?.kind==='person'?n.id===activeNode.id:comparison?compared.some(p=>p.id===n.id):n.workIds.some(id=>related.has(id))):n.workIds.some(id=>related.has(id))),included=!matches||n.kind==='root'||n.id===activeId||n.workIds.some(id=>matches.has(id));el.classList.toggle('dim',!included||!near);el.classList.toggle('hot',n.id===hoverId||n.id===activeId);el.classList.toggle('selected',n.id===activeId);} const selected=activeNode?.kind==='work'&&trail.at(-1)?.kind==='work'&&trail.at(-1).id===activeNode.workIds[0]?activeNode.id:null; const hovered=['work','format','person'].includes(graph.byId.get(hoverId)?.kind)?hoverId:null; for(let i=edgeElements.length-1;i>=0;i--)if(edgeElements[i].temporary){edgeElements[i].el.remove();edgeElements.splice(i,1);} for(const id of new Set([selected,hovered].filter(id=>graph.byId.get(id)?.kind==='work'))){const work=graph.byId.get(id),format=graph.nodes.find(n=>n.kind==='format'&&n.workIds.includes(work.workIds[0]));if(format&&!edgeElements.some(({edge})=>edge.kind==='format'&&edge.source===format.id&&edge.target===id))addFormatLine(format.id,id,true);} $('graph').dataset.connections=connections?'on':'off'; for(const {edge,el}of edgeElements){ const touches=id=>id&&(edge.source===id||edge.target===id); const included=!matches||[edge.source,edge.target].every(id=>graph.byId.get(id)?.workIds.some(workId=>matches.has(workId))); const personFocus=activeNode?.kind==='person',compareLine=comparison&&edge.kind==='credit'&&compared.some(p=>touches(p.id));el.classList.toggle('hot',Boolean(personFocus?touches(activeNode.id):touches(selected)||touches(hovered)||activeNode?.kind==='format'&&touches(activeNode.id)||compareLine)); el.classList.toggle('dim',!included||Boolean(activeNode?.kind==='person'?edge.kind==='credit'?edge.source!==activeNode.id:!([edge.source,edge.target].every(id=>graph.byId.get(id)?.workIds.some(workId=>related.has(workId)))):(activeNode||comparison)&&!touches(selected)&&[edge.source,edge.target].some(id=>!graph.byId.get(id)?.workIds.some(workId=>related.has(workId))))); } updateSelectionPull(selected);paint(); } function selectNode(n){if(!n)return;if(n.kind==='format'){void appOptions.onOpenFormat?.(n.label);return;}if(n.kind==='work'&&!graph?.byId.has(n.id)){void openSavedWork(n.pageId||n.workIds[0]);return;}if(n.kind==='person'&&activeId===n.id&&trail.at(-1)?.kind==='person'){closeDrawer();return;}setFilterOpen(false);setConnectionsOpen(false);hoverId=null;hidePosterHint();if(n.kind==='root'){closeDrawer();hideSuggestions();fit();return;}activeId=n.id;hideSuggestions();if(n.kind==='world'){void openWorld(n);return;}if(n.kind==='work')openDetail({kind:'work',id:workForNode(n)?.[0]||n.pageId});else if(n.kind==='person'){openPerson(peopleIndex.byId.get(n.id)||n,{travelTo:false});return;}else if(n.kind==='director'||n.kind==='author'){const person=peopleIndex.people.find(p=>p.searchNodeIds.includes(n.id));if(person){openPerson(person);return;}}else openDetail({kind:'collection',id:n.id,label:n.label,workIds:n.workIds,role:n.kind});highlight();travel(n,true);} function focusSavedWork(id){const row=worksByPageId.get(pageId(id));if(!row)return false;const format=DATA.f[row[2]]||'';if(currentFormat!==format){appOptions.onOpenFormat?.(format,{alreadyActivated:true});activateFormat(format,{keepDetail:true});}const n=graph?.byId.get('work:'+pageId(id));if(!n)return false;if(keepDetailWhileFocusingSavedWork(trail.at(-1)?.kind)){renderDetail();activeId=n.id;hoverId=null;hidePosterHint();highlight();travel(n,true);}else selectNode(n);return true;} function renderFilters(){if(!searchGraph||!DATA)return;const container=$('filterGroups');const open=new Set([...container.querySelectorAll('details[open]')].map(d=>d.dataset.group));container.replaceChildren(); const groups=[...(currentFormat==='Movie'?[['format','Movie Region']]:[]),['world','World'],['people','People'],['decade','Release Year'],['status','Status']]; const option=(group,id,label,color='')=>{const row=document.createElement('label');row.className='filter-option';row.dataset.search=key(label);const input=document.createElement('input');input.type='checkbox';input.checked=filters[group].has(id);input.setAttribute('aria-label',label);input.onchange=()=>{input.checked?filters[group].add(id):filters[group].delete(id);if(group==='region'&&filters.region.size)filters.format.add('Movie');if(group==='format'&&id==='Movie'&&!input.checked)filters.region.clear();syncChips();highlight();openFiltered();};row.append(input);if(color){const dot=document.createElement('i');dot.style.background=color;row.append(dot);}row.append(document.createTextNode(label));return row;}; for(const [group,label]of groups){const details=document.createElement('details');details.className='filter-group';details.dataset.group=group;details.open=open.has(group);const summary=document.createElement('summary');summary.textContent=label.toUpperCase();details.append(summary);const list=document.createElement('div');list.className='filter-options';details.append(list); if(group==='world'){const clear=document.createElement('button');clear.id='allWorldFilter';clear.className='chip';clear.textContent=(filters.world.size?'':'✓ ')+'ALL WORLD';clear.onclick=()=>{filters.world.clear();renderFilters();highlight();openFiltered();};list.append(clear);for(const n of worldNodes().sort((a,b)=>a.label.localeCompare(b.label,'ja'))){const row=document.createElement('div');row.className='world-filter-row';const filter=option(group,n.id,n.label,n.color),input=filter.querySelector('input'),change=input.onchange;input.onchange=async()=>{change();if(n.id.startsWith('cw_')&&input.checked){await hydrateCustomWorld(n.id);renderFilters();highlight();openFiltered();}};const detail=document.createElement('button');detail.className='world-detail-button';detail.textContent='↗';detail.setAttribute('aria-label',n.label+' のWORLD詳細を開く');detail.onclick=()=>void openWorld(n);row.append(filter,detail);list.append(row);}const create=document.createElement('button');create.className='subtle-button world-create-button';create.textContent='+ CREATE WORLD';create.onclick=openCreateWorld;clear.after(create);if(worldListError){const note=document.createElement('p');note.className='muted';note.textContent=worldListError;const retry=document.createElement('button');retry.className='text-button';retry.textContent='Retry';retry.onclick=()=>void loadWorldList();list.append(note,retry);}details.addEventListener('toggle',()=>{if(details.open)void loadWorldList();});} else if(group==='format'){for(const f of ['Movie'].filter(f=>DATA.f.includes(f))){list.append(option(group,f,f,colors[f]));if(f==='Movie'){const nest=document.createElement('details');nest.className='filter-nest';nest.innerHTML='Country / Region';['邦画','洋画'].forEach(region=>nest.append(option('region',region,region)));list.append(nest);}}list.insertAdjacentHTML('beforeend','

制作国を確認できたMovieを邦画/洋画に分類します。

');} else if(group==='status'){['未視聴','視聴済み','未入力'].forEach(status=>list.append(option(group,status,status)));} else if(group==='decade'){const years=[...new Set(DATA.w.map(w=>w[3]?String(Math.floor(w[3]/10)*10)+'s':'Unknown'))].sort();years.forEach(year=>list.append(option(group,year,year)));} else{const search=document.createElement('input');search.type='search';search.className='filter-search';search.placeholder='Search people...';search.setAttribute('aria-label','人物の絞り込み候補を検索');details.insertBefore(search,list);const choices=[...peopleIndex.people].sort((a,b)=>a.name.localeCompare(b.name,'ja'));const show=()=>{list.replaceChildren();const matching=choices.filter(person=>!search.value||[person.name,...person.aliases].some(name=>key(name).includes(key(search.value))));for(const person of matching.slice(0,150))list.append(option(group,person.id,person.name));if(matching.length>150){const note=document.createElement('p');note.className='muted';note.textContent='先頭150人を表示。名前を入力するとほかの人物も探せます。';list.append(note);}};search.oninput=show;details.addEventListener('toggle',()=>{if(details.open&&!list.childElementCount)show();});if(details.open)show();} container.append(details); }syncChips();renderFilterResults(); } function syncChips(){const box=$('chips');box.replaceChildren();let count=0;for(const [group,set]of Object.entries(filters))for(const id of set){count++;const n=searchGraph.byId.get(id)||peopleIndex?.byId.get(id),label=n?.label||id,button=document.createElement('button');button.className='chip';button.textContent=label+' ×';button.setAttribute('aria-label',label+' の絞り込みを解除');button.onclick=()=>{set.delete(id);if(group==='format'&&id==='Movie')filters.region.clear();renderFilters();highlight();openFiltered();};box.append(button);}box.hidden=count===0;if($('allWorldFilter'))$('allWorldFilter').textContent=(filters.world.size?'':'✓ ')+'ALL WORLD';$('filterCount').textContent=count||'';$('filterToggle').classList.toggle('active',!!count);} function renderFilterResults(){ const container=$('filterResults');if(!container)return; const filtered=Object.values(filters).some(set=>set.size),ids=[...matchingIds()]; container.innerHTML=filtered?`

MATCHES · ${ids.length}

${sortControl('filterSort')}
`+(ids.length?workList(ids):'

該当する作品はありません。

'):'

複数の条件を選んで、作品を探せます。

'; bindSort('filterSort');container.querySelectorAll('[data-work]').forEach(el=>el.onclick=()=>{void openSavedWork(el.dataset.work);}); } function setFilterOpen(open){ if(open===!$('filters').hidden)return; if(open){closeDrawer();setConnectionsOpen(false);}$('filters').hidden=!open;$('workspace').classList.toggle('has-filters',open);$('filterToggle').setAttribute('aria-expanded',String(open));hideSuggestions(); if(open){renderFilterResults();if(Object.values(filters).some(set=>set.size))frameMatches();else fit();} else if(overview)fit();else applyCamera(); } function frameMatches(){const ids=[...matchingIds()];if(ids.length)travel({kind:'collection',workIds:ids},true);else fit();} function openFiltered(){ activeId=null;hoverId=null;highlight();renderFilterResults(); if(!Object.values(filters).some(s=>s.size)){if(trail.at(-1)?.kind==='filtered')closeDrawer();fit();return;} if($('filters').hidden)openDetail({kind:'filtered',label:'Filtered universe',workIds:[...matchingIds()]},false); frameMatches(); } function hideSuggestions(){clearTimeout(searchTimer);searchTimer=null;$('suggestions').hidden=true;$('search').setAttribute('aria-expanded','false');$('search').removeAttribute('aria-activedescendant');} function setSearchOpen(open,{resetFocus=true}={}){if(open===!$('searchBox').hidden)return;$('searchBox').hidden=!open;$('searchWrap').classList.toggle('expanded',open);$('searchOpen').setAttribute('aria-expanded',String(open));if(open)$('search').focus();else{$('search').value='';hideSuggestions();if(resetFocus&&!trail.length){activeId=null;focusedPersonId=null;refreshPeopleLayer({reindex:false});highlight();}}} function search(){clearTimeout(searchTimer);if(!searchGraph||!DATA)return;if(!$('search').value.trim()){hideSuggestions();return;}for(const n of searchGraph.nodes)if(n.kind==='work'){const info=DATA.workDetails?.[n.workIds[0]];if(info)n.searchLabels=[...new Set([...n.searchLabels,info.title,info.originalTitle].filter(Boolean))];} const base=searchOrbitGraph(searchGraph,$('search').value,14).filter(n=>!['director','author'].includes(n.kind)),term=key($('search').value); const people=peopleIndex.people.filter(p=>[p.name,...p.aliases].some(name=>key(name).includes(term))).map(p=>({...p,kind:'person'}));suggestions=[...base,...people].slice(0,10);suggestionIndex=0;drawSuggestions();searchTimer=setTimeout(()=>previewSuggestion(),220);} function drawSuggestions(){const box=$('suggestions');box.replaceChildren();box.hidden=false;$('search').setAttribute('aria-expanded','true');if(!suggestions.length){box.innerHTML='
見つかりませんでした。+ ADD WORKから追加できます。
';return;}suggestions.forEach((n,i)=>{const row=document.createElement('div');row.className='suggestion'+(i===suggestionIndex?' active':'');row.setAttribute('role','option');row.setAttribute('aria-selected',String(i===suggestionIndex));row.id='suggestion-'+i;row.innerHTML=`${esc(n.label)}${labelKinds[n.kind]}`;row.onpointerenter=()=>{suggestionIndex=i;drawSuggestionSelection();previewSuggestion();};row.onclick=()=>commitSuggestion(n);box.append(row);});drawSuggestionSelection();} function drawSuggestionSelection(){[...$('suggestions').children].forEach((el,i)=>{el.classList.toggle('active',i===suggestionIndex);el.setAttribute('aria-selected',String(i===suggestionIndex));});$('search').setAttribute('aria-activedescendant','suggestion-'+suggestionIndex);} function actualNode(n){return graph?.byId.get(n.id)||n;} function previewSuggestion(){const n=suggestions[suggestionIndex];if(!n||!graph||n.kind==='work'&&!graph.byId.has(n.id))return;if(n.kind==='person'){focusedPersonId=n.id;refreshPeopleLayer();}const actual=actualNode(n);activeId=n.id;highlight();travel(actual,false);} function commitSuggestion(n){if(n.kind==='person')openPerson(peopleIndex.byId.get(n.id)||n);else selectNode(actualNode(n));hideSuggestions();setSearchOpen(false);} function closeDrawer(){worldOpenGeneration++;worldView.deactivate?.();const wasOpen=!$('drawer').hidden;$('drawer').hidden=true;$('workspace').classList.remove('has-detail');trail=[];activeId=null;hoverId=null;focusedPersonId=null;refreshPeopleLayer({reindex:false});hidePosterHint();highlight();if(wasOpen)fit();else applyCamera();} function drawerHeader(kind){return `
${trail.length>1?'':''}${esc(kind)}
`;} function sortControl(id){return ``;} function bindSort(id){const select=$(id);if(select)select.onchange=()=>{sortMode=select.value;renderFilterResults();if(trail.length)renderDetail();};} function workList(ids,mode=sortMode){return '
'+sortedWorkIds(ids,DATA,mode).map(id=>{const w=DATA.w.find(row=>row[0]===id),img=picture(DATA,w[7]);return ``;}).join('')+'
';} function workGallery(ids,mode=sortMode){return '';} function avatar(person,cls=''){const url=safeURL(person.profile);return url?``:`${esc(Array.from(person.name||person.label||'?').slice(0,2).join(''))}`;} function personButton(person,role,characters=[]){return ``;} function applyResolvedPeople(){if(!peopleIndex)return;for(const [id,chosen]of resolvedPeople){const pending=peopleIndex.byId.get(id);const actualId='person:tmdb:'+chosen.id;if(!pending){if(peopleIndex.byId.has(actualId))remapPersonId(id,actualId);continue;}const existing=peopleIndex.byId.get(actualId);if(existing&&existing!==pending){for(const role of Object.keys(ROLES))existing.roleWorkIds[role]=[...new Set([...(existing.roleWorkIds[role]||[]),...(pending.roleWorkIds[role]||[])])];existing.workIds=[...new Set([...existing.workIds,...pending.workIds])];existing.roles=[...new Set([...existing.roles,...pending.roles])];existing.credits.push(...pending.credits);existing.searchNodeIds.push(...pending.searchNodeIds);existing.aliases=[...new Set([...existing.aliases,...pending.aliases])];existing.roleLabels=existing.roles.map(role=>ROLES[role]);peopleIndex.people=peopleIndex.people.filter(p=>p!==pending);peopleIndex.byId.delete(id);}else{Object.assign(pending,{id:actualId,tmdbId:String(chosen.id),profile:chosen.profile||pending.profile,aliases:[...new Set([...pending.aliases,chosen.name,chosen.originalName].filter(Boolean))]});peopleIndex.byId.delete(id);peopleIndex.byId.set(actualId,pending);}remapPersonId(id,actualId);} for(const person of peopleIndex.people){for(const credit of personCache.get('credits:'+person.tmdbId)?.credits||[]){const ids=matchCreditToLibrary(credit,DATA),roles=credit.asCast?['cast']:(credit.jobs||[]).flatMap(job=>/^(Director|Co-Director)$/.test(job)?['director']:/^(Writer|Screenplay|Story|Teleplay)$/.test(job)?['writer']:[]);for(const role of roles)for(const id of ids){person.roleWorkIds[role]??=[];if(!person.roleWorkIds[role].includes(id))person.roleWorkIds[role].push(id);if(!person.workIds.includes(id))person.workIds.push(id);if(!person.roles.includes(role))person.roles.push(role);}}person.roleLabels=person.roles.map(role=>ROLES[role]);const profile=personCache.get('profile:'+person.tmdbId)?.person;if(profile){person.profile=profile.profile||person.profile;person.aliases=[...new Set([...person.aliases,...(profile.alsoKnownAs||[]),profile.name].filter(Boolean))];}} peopleIndex.byWork=new Map();for(const person of peopleIndex.people)for(const id of person.workIds){if(!peopleIndex.byWork.has(id))peopleIndex.byWork.set(id,[]);peopleIndex.byWork.get(id).push(person);} } function peopleLayoutFor(){const prior=graph?.nodes.filter(n=>n.kind==='person')||[],base={...graph,nodes:graph.nodes.filter(n=>n.kind!=='person')};const ids=formatWorkIds(DATA,currentFormat),scenePeople={...peopleIndex,people:peopleIndex.people.map(p=>({...p,workIds:p.workIds.filter(id=>ids.has(id)),roleWorkIds:Object.fromEntries(Object.entries(p.roleWorkIds).map(([role,works])=>[role,works.filter(id=>ids.has(id))]))})).filter(p=>p.workIds.length)};return layoutPeopleNodes(scenePeople,base,{roles:connectionRoles,selectedPeopleIds:peopleControls.selectedPeopleIds,pinnedPeopleIds:peopleControls.pinnedPeopleIds,hiddenPeopleIds:peopleControls.hiddenPeopleIds,focusedPersonId,previous:prior,maxNodes:120});} function reportPeopleLayout(layout){$('connectionsCount').textContent=peopleControls.selectedPeopleIds.size+peopleControls.pinnedPeopleIds.size?String(new Set([...peopleControls.selectedPeopleIds,...peopleControls.pinnedPeopleIds]).size):'';$('peopleMapStatus').textContent=layout.truncated?`${layout.visibleCount}人を表示中。名前で検索して個別に表示できます。`:'名前で選ぶと、役割の設定にかかわらずその人物を表示します。';renderSelectedPeople();renderConnectionPeopleResults();} function drawPersonBody(body,n){const r=n.radius,clipId='person-clip-'+encodeURIComponent(n.id).replace(/%/g,'');body.append(svg('circle',{r:r+3,fill:'#182329',stroke:n.color,class:'person-border'}));const url=safeURL(n.profile||personCache.get('profile:'+n.tmdbId)?.person?.profile);if(url){const clip=svg('clipPath',{id:clipId});clip.append(svg('circle',{r}));body.append(clip,svg('image',{href:url,x:-r,y:-r,width:r*2,height:r*2,preserveAspectRatio:'xMidYMid slice','clip-path':`url(#${clipId})`}));}else body.append(svg('text',{class:'person-initials',y:7},Array.from(n.label||'?').slice(0,2).join('')));body.append(svg('text',{class:'person-map-name',y:r+22},shortLabel(n.label,n.width)));} function drawPeopleLinks(){for(const edge of graph.personLinks||[]){const el=svg('path',{class:'graph-link person-link',stroke:graph.byId.get(edge.source)?.color||'#b9d2cf','data-source':edge.source,'data-target':edge.target});$('links').append(el);edgeElements.push({edge,el});}} function refreshPeopleLayer({reindex=true}={}){if(!DATA||!graph||!searchGraph)return;const prior=graph.nodes.filter(n=>n.kind==='person');if(reindex)reindexPeople();const layout=peopleLayoutFor();for(const n of prior){elements.get(n.id)?.remove();elements.delete(n.id);graph.byId.delete(n.id);}graph.nodes=graph.nodes.filter(n=>n.kind!=='person');for(let i=edgeElements.length-1;i>=0;i--)if(edgeElements[i].edge.kind==='credit'){edgeElements[i].el.remove();edgeElements.splice(i,1);}graph.personLinks=layout.links; for(const n of layout.nodes){n.ax=n.x;n.ay=n.y;graph.nodes.push(n);graph.byId.set(n.id,n);const el=svg('g',{class:'orbit-node person',tabindex:0,role:'button','aria-label':n.label+' · PERSON','data-node-id':n.id}),body=svg('g',{class:'node-body'});el.append(svg('title',{},n.label),body);drawPersonBody(body,n);el.onpointerenter=()=>{if(hoverSuppressed())return;hoverId=n.id;highlight();};el.onpointerleave=()=>{if(hoverSuppressed())return;hoverId=null;highlight();};el.onfocus=()=>{hoverId=n.id;highlight();};el.onblur=()=>{hoverId=null;highlight();};el.onclick=()=>{if(!selectionBeforeDrag)selectNode(n);};el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();selectNode(n);}};elements.set(n.id,el);$('nodes').append(el);}drawPeopleLinks();reportPeopleLayout(layout);paint();highlight();applyCamera();} function setConnectionsOpen(open){$('connectionsMenu').hidden=!open;$('connectionsToggle').setAttribute('aria-expanded',String(open));if(open)setFilterOpen(false);} function renderConnectionOptions(){const box=$('connectionRoles');box.replaceChildren();for(const [role,label]of Object.entries(ROLES)){const row=document.createElement('label');row.className='filter-option';const check=document.createElement('input');check.type='checkbox';check.checked=connectionRoles.has(role);check.onchange=()=>updatePeopleControls(setPeopleRoleEnabled(peopleControls,role,check.checked));row.append(check,document.createTextNode(label));box.append(row);}renderSelectedPeople();renderConnectionPeopleResults();} function renderSelectedPeople(){const box=$('selectedPeople');if(!box||!peopleIndex)return;box.replaceChildren();const ids=new Set([...peopleControls.selectedPeopleIds,...peopleControls.pinnedPeopleIds]);for(const id of ids){const person=peopleIndex.byId.get(id);if(!person)continue;const row=document.createElement('div');row.className='selected-person';const focus=document.createElement('button');focus.className='selected-person-focus';focus.innerHTML=avatar(person)+`${esc(person.name)}`;focus.onclick=()=>openPerson(person);const remove=document.createElement('button');remove.className='text-button';remove.textContent='×';remove.setAttribute('aria-label',person.name+' をマップから非表示');remove.onclick=()=>{if(activeId===id)closeDrawer();updatePeopleControls(hidePerson(peopleControls,id));};row.append(focus,remove);box.append(row);}if(!box.children.length)box.innerHTML='

人物を検索して選択できます。

';$('clearPeople').disabled=!ids.size&&!connectionRoles.size&&!peopleControls.hiddenPeopleIds.size;} function renderConnectionPeopleResults(){const box=$('connectionPeopleResults'),query=$('connectionPeopleSearch')?.value.trim();if(!box||!peopleIndex)return;box.replaceChildren();if(!query)return;const choices=filterPeople(peopleIndex.people,query).sort((a,b)=>a.name.localeCompare(b.name,'ja'));for(const person of choices.slice(0,12)){const button=document.createElement('button');button.className='person-row';button.setAttribute('aria-label',person.name+' をマップに表示');button.innerHTML=avatar(person)+`${esc(person.name)}${esc(person.roleLabels.join(' / '))} · ${person.workIds.length} WORKS${peopleControls.selectedPeopleIds.has(person.id)?'✓':'+'}`;button.onclick=()=>{closeDrawer();updatePeopleControls(selectPerson(peopleControls,person.id));frameSelectedPeople();};box.append(button);}if(!choices.length)box.innerHTML='

保存済み作品の人物が見つかりません。People Sync完了後にも検索できます。

';} function frameSelectedPeople(){if(!graph||currentFormat===null)return;const ids=[...new Set([...peopleControls.selectedPeopleIds,...peopleControls.pinnedPeopleIds])],nodes=ids.map(id=>graph.byId.get(id)).filter(Boolean),works=nodes.flatMap(n=>n.workIds).map(id=>graph.byId.get('work:'+id)).filter(Boolean);if(!nodes.length){fit();return;}const bounds=graphBounds([...nodes,...works],180),r=measureField();overview=false;cancelAnimationFrame(animation);camera={x:bounds.x,y:bounds.y,width:Math.max(1100,bounds.width,bounds.height*r.width/Math.max(1,r.height))};applyCamera();highlight();} function backDetail(){worldView.deactivate?.();trail.pop();const previous=trail.at(-1);if(!previous){closeDrawer();return;}if(previous.format!==currentFormat){if(previous.format===null){suspendOrbit({keepDetail:true});appOptions.onHome?.({keepDetail:true});}else{appOptions.onOpenFormat?.(previous.format,{alreadyActivated:true});activateFormat(previous.format,{keepDetail:true});}}focusedPersonId=previous.kind==='person'?previous.id:null;refreshPeopleLayer({reindex:false});renderDetail();const n=graph?.byId.get(previous.kind==='work'?'work:'+previous.id:previous.id)||searchGraph.byId.get(previous.id);if(n){activeId=n.id;highlight();travel(n);}} function bindDrawer(){if($('detailClose'))$('detailClose').onclick=closeDrawer;if($('detailBack'))$('detailBack').onclick=backDetail; $('drawer').querySelectorAll('[data-work]').forEach(el=>el.onclick=()=>{void openSavedWork(el.dataset.work);}); $('drawer').querySelectorAll('[data-person]').forEach(el=>el.onclick=()=>{const p=peopleIndex.byId.get(el.dataset.person);if(p)openPerson(p);}); $('drawer').querySelectorAll('[data-group]').forEach(el=>el.onclick=()=>{const n=searchGraph.byId.get(el.dataset.group);if(n)selectNode(n);});bindSort('detailSort'); } function openDetail(detail,push=true){detail.format=currentFormat;worldView.deactivate?.();setFilterOpen(false);setConnectionsOpen(false);if(detail.kind==='work'){focusedPersonId=null;refreshPeopleLayer({reindex:false});}if(push){if(trail.at(-1)?.id!==detail.id||trail.at(-1)?.kind!==detail.kind)trail.push(detail);}else trail=[detail];$('drawer').hidden=false;$('workspace').classList.add('has-detail');renderDetail();highlight();applyCamera();} const castModes=new Map(),workHydrated=new Set(),dubEditors=new Set(); function renderDetail(){const detail=trail.at(-1);if(!detail)return;const drawer=$('drawer');if(detail.kind==='world'){void worldView.render(detail);return;}if(detail.kind==='world-create'){worldView.openCreate();return;}if(detail.kind==='person'){renderPeopleDetail(detail);return;}if(detail.kind!=='work'){const list=(detail.kind==='filtered'?[...matchingIds()]:detail.workIds||[]).filter(id=>DATA.w.some(w=>w[0]===id));drawer.innerHTML=drawerHeader(detail.role?.toUpperCase()||'COLLECTION')+`

${esc(detail.label)}

${list.length} CONNECTED WORKS

${sortControl('detailSort')}
`+workList(list);bindDrawer();return;} const w=DATA.w.find(w=>w[0]===detail.id);if(!w){closeDrawer();return;}const id=w[0],info=DATA.workDetails?.[id]||{},legacy=DATA.details?.[id],poster=picture(DATA,w[7]);let html=drawerHeader('WORK')+`
${poster?`${esc(w[1])} ポスター`:''}
${esc(DATA.f[w[2]])}${DATA.f[w[2]]==='Movie'&&movieRegion(info)?' / '+movieRegion(info):''}

${esc(w[1])}

${w[3]||''}${w[6]!=null?''+(['未視聴','視聴済み'][w[6]]||'')+'':''}${w[9]?`${'★'.repeat(w[9])+'☆'.repeat(5-w[9])}`:''}
`; if(info.overview)html+=`

${esc(info.overview)}

`; const known=peopleIndex.byWork.get(id)||[];for(const role of ['director','author','writer']){const people=known.filter(p=>p.roleWorkIds[role]?.includes(id));if(people.length)html+=`

${ROLES[role].toUpperCase()}

${people.map(p=>personButton(p,role)).join('')}
`;} const memberships=(membershipByWork.get(id)||[]).filter(n=>['world','series'].includes(n.kind));if(memberships.length)html+=`

WORLD / SERIES

${memberships.map(n=>``).join('')}
`; const seasons=info.seasons?.length?info.seasons:legacy?.seasons||[];if(seasons.length)html+='

SEASONS

'+seasons.map(s=>`
${esc(s.name||s.label||'Season '+s.number)}

${esc([s.year,s.episodes?s.episodes+'話':''].filter(Boolean).join(' · '))||'シーズン情報はNotionでも確認できます。'}

`).join(''); const mode=castModes.get(id)||'original',dub=info.japaneseDubCast?.confirmed?info.japaneseDubCast:dubbingCache.get(id),cast=mode==='dub'?(dub?.cast||[]):(info.cast||castCache.get(id)?.cast||[]),role=mode==='dub'?'japaneseDubCast':'cast'; html+=`

CAST / VOICE CAST

`; if(mode==='dub')html+=dub?.confirmed?`

${esc(dub.edition||'日本語吹替')} · ${esc(dub.source.label)} ↗

`:'

出典を確認できた日本語吹替はまだ登録されていません。原語版の出演者から推測しません。

'; else html+='

TMDbの原語版クレジットです。日本語吹替とは分けて表示しています。

'; if(cast.length)html+='
'+cast.map(p=>{const person=known.find(n=>p.id&&String(n.tmdbId)===String(p.id)||n.name===p.name&&n.roleWorkIds[role]?.includes(id));return person?personButton(person,role,p.characters||[]):'';}).join('')+'
'; else if(mode==='original')html+=`

${workHydrated.has(id)?'確認できる出演情報がありません。':'出演者を取得しています…'}

`; if(mode==='dub'){html+='';if(dubEditors.has(id))html+=dubEditor(dub);} if(castCache.get(id)?.status==='needs_confirmation')html+='

作品を特定できませんでした。NotionのURL欄を確認してください。

'; const related=[...relatedWorkIds(id)];if(related.length)html+=`

RELATED WORKS · ${related.length}

${sortControl('detailSort')}
`+workGallery(related); html+=`

人物・作品情報: TMDb。本サービスはTMDbの承認を受けたものではありません。

`; drawer.innerHTML=html;bindDrawer();drawer.querySelectorAll('[data-cast-mode]').forEach(el=>el.onclick=()=>{castModes.set(id,el.dataset.castMode);renderDetail();});if($('editDub'))$('editDub').onclick=()=>{dubEditors.has(id)?dubEditors.delete(id):dubEditors.add(id);renderDetail();};if($('dubForm'))$('dubForm').onsubmit=e=>{e.preventDefault();void saveDubbing(id);};if(!workHydrated.has(id))void hydrateWork(id);for(const load of [DATA.loadWorkDetails,DATA.loadWorkConnections])if(load)void load(id).catch(()=>{}); } async function requestJSON(path,options={}){const response=await fetch(path,{credentials:'same-origin',cache:'no-store',redirect:'error',signal:AbortSignal.timeout(45000),...options});let result;try{result=await response.json();}catch{throw new Error('ログイン状態を確認してからRefreshしてください。');}if(!response.ok)throw new Error(result.message||'情報を取得できませんでした。');return result;} async function loadCast(id,refresh=true){if(castCache.has(id))return castCache.get(id);if(castFlights.has(id))return castFlights.get(id);const flight=requestJSON('/api/works/cast?page='+encodeURIComponent(id)).then(result=>{if(!DATA.w.some(w=>w[0]===id))return result;castCache.set(id,result);if(result.status==='ready'){DATA.workDetails??={};DATA.workDetails[id]={...DATA.workDetails[id],...result.work,cast:result.cast||[]};if(result.work?.tmdbId)DATA.workDetails[id].id=result.work.tmdbId;if(refresh)refreshPeopleLayer();}return result;}).finally(()=>castFlights.delete(id));castFlights.set(id,flight);return flight;} async function hydrateWork(id){workHydrated.add(id);try{await Promise.all([loadCast(id),loadDubbing(id)]);renderFilters();if(trail.at(-1)?.kind==='work'&&trail.at(-1).id===id)renderDetail();}catch(error){workHydrated.delete(id);if(trail.at(-1)?.id===id){const errorBox=document.createElement('p');errorBox.className='cast-status';errorBox.textContent=error.message;const retry=document.createElement('button');retry.textContent='もう一度取得';retry.onclick=()=>void hydrateWork(id);errorBox.append(document.createElement('br'),retry);$('drawer').append(errorBox);}}} async function loadDubbing(id){if(dubbingCache.has(id))return dubbingCache.get(id);const result=await requestJSON('/api/works/dubbing?page='+encodeURIComponent(id));dubbingCache.set(id,result.japaneseDubCast||null);if(result.japaneseDubCast){DATA.workDetails??={};DATA.workDetails[id]={...DATA.workDetails[id],japaneseDubCast:result.japaneseDubCast};refreshPeopleLayer();}return result.japaneseDubCast;} function dubEditor(dub){return `

`;} async function saveDubbing(id){const form=$('dubForm'),status=$('dubSaveStatus'),button=form.querySelector('button');if(!form.reportValidity())return;const fields=new FormData(form),old=DATA.workDetails?.[id]?.japaneseDubCast;const cast=String(fields.get('cast')).split('\n').map(line=>line.trim()).filter(Boolean).map(line=>{const [name,...characters]=line.split('|');const previous=old?.cast.find(p=>p.name===name.trim());return {...(previous?.id?{id:previous.id,profile:previous.profile}:{}),name:name.trim(),characters:characters.join('|').split('/').map(s=>s.trim()).filter(Boolean)};});button.disabled=true;status.textContent='Notionに保存しています…';try{const result=await requestJSON('/api/works/dubbing?page='+encodeURIComponent(id),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({japaneseDubCast:{confirmed:fields.get('confirmed')==='on',source:{label:fields.get('label'),url:fields.get('url')},edition:fields.get('edition'),cast}})});dubbingCache.set(id,result.japaneseDubCast);DATA.workDetails[id]={...DATA.workDetails[id],japaneseDubCast:result.japaneseDubCast};dubEditors.delete(id);refreshPeopleLayer();renderFilters();renderDetail();toast('日本語吹替をNotionに保存しました。');}catch(error){status.textContent=error.message;}finally{button.disabled=false;}} function openPerson(person,{travelTo=true}={}){if(!person)return;peopleControls=showPerson(peopleControls,person.id);persistPeopleControls();focusedPersonId=person.id;refreshPeopleLayer({reindex:false});const current=peopleIndex.byId.get(person.id)||person;activeId=current.id;openDetail({kind:'person',id:current.id,person:current,workIds:current.workIds});const node=graph?.byId.get(current.id);if(node&&travelTo)travel(node,true);} function personFilmographyState(id){if(!filmographyFilters.has(id))filmographyFilters.set(id,{query:'',scope:'all',sort:'oldest',pastOpen:false,upcomingOpen:false});return filmographyFilters.get(id);} function renderPeopleDetail(detail){const person=peopleIndex.byId.get(detail.id)||detail.person,profile=personCache.get('profile:'+person.tmdbId)?.person||person,state=personFilmographyState(person.id);detail.person=person;let html=drawerHeader('PEOPLE')+`
${avatar({...person,profile:profile.profile||person.profile},'hero-avatar')}

${esc(person.name)}

${esc(person.roleLabels?.join(' / ')||person.roles.map(role=>ROLES[role]).join(' / '))}

`; if(profile.biography)html+=`
PROFILE

${esc(profile.biography)}

`; html+=`
${[['all','ALL'],['saved','SAVED'],['unsaved','UNSAVED']].map(([value,label])=>``).join('')}
`; if(person.tmdbId)html+=``;else html+='

人物IDが未確認です。同名の人物を取り違えないよう、候補から選んでください。

'; html+='

人物・作品情報: TMDb。最新作は取得時点の公開済み作品です。

';$('drawer').innerHTML=html;renderPersonResults(person);bindDrawer();$('focusPerson').onclick=()=>{$('drawer').hidden=true;$('workspace').classList.remove('has-detail');trail=[];focusedPersonId=person.id;activeId=person.id;refreshPeopleLayer({reindex:false});highlight();const node=graph?.byId.get(person.id);if(node)travel(node,true);};$('hidePerson').onclick=()=>{updatePeopleControls(hidePerson(peopleControls,person.id));closeDrawer();};$('filmographySearch').oninput=()=>{state.query=$('filmographySearch').value;renderPersonResults(person);};$('filmographySort').onchange=()=>{state.sort=$('filmographySort').value;renderPersonResults(person);};$('drawer').querySelectorAll('[data-filmography-scope]').forEach(button=>button.onclick=()=>{state.scope=button.dataset.filmographyScope;$('drawer').querySelectorAll('[data-filmography-scope]').forEach(other=>other.setAttribute('aria-pressed',String(other===button)));renderPersonResults(person);});if($('searchPerson'))$('searchPerson').onclick=()=>void searchPersonCandidates(person);if(person.tmdbId&&!personCache.has('credits:'+person.tmdbId))void loadPeopleDetail(person); } function renderPersonResults(person){const box=$('peopleResults');if(!box)return;const state=personFilmographyState(person.id),loaded=personCache.get('credits:'+person.tmdbId),credits=loaded?.credits||[],term=key(state.query),matches=id=>{const w=DATA.w.find(row=>row[0]===id);return w&&(!term||[w[1],DATA.workDetails?.[id]?.originalTitle].some(title=>key(title).includes(term)));},sections=creditSections(credits,DATA),allMatches=filterPersonFilmography(credits,DATA,state.query,'all'),unsavedIds=new Set(sections.unsaved.map(c=>c.mediaType+':'+c.id)),matchedStored=new Set(allMatches.flatMap(c=>matchCreditToLibrary(c,DATA))),own=[...new Set([...(person.workIds||[]),...sections.stored])].filter(id=>matches(id)||matchedStored.has(id));let html='';if(state.scope!=='unsaved'){html+=`

IN YOUR ORBIT · ${own.length}

`+(own.length?workGallery(own,state.sort):'

該当する保存済み作品はありません。

');const dub=(person.roleWorkIds?.japaneseDubCast||[]).filter(matches);if(dub.length)html+=`
JAPANESE DUB WORKS · ${dub.length}${workList(dub,state.sort)}

出典を確認して保存した日本語吹替の作品です。

`;} if(person.tmdbId){if(loaded){const filtered=allMatches.filter(c=>state.scope==='all'||(state.scope==='unsaved'?unsavedIds.has(c.mediaType+':'+c.id):!unsavedIds.has(c.mediaType+':'+c.id))),latest=latestPersonCreditInScope(sections.latest,filtered);html+=peopleCreditHTML(filtered,state.sort,{latest,pastOpen:state.pastOpen,upcomingOpen:state.upcomingOpen});if(!filtered.length&&!latest)html+='

該当する作品はありません。

';}else html+='

過去作品と最新作品を取得しています…

';}else if(state.scope==='unsaved')html+='

人物候補を確定すると、過去作品と未保存作品を検索できます。

'; box.innerHTML=html;box.querySelectorAll('[data-work]').forEach(el=>el.onclick=()=>{void openSavedWork(el.dataset.work);});box.querySelectorAll('[data-external-credit]').forEach(el=>el.onclick=()=>{const credit=credits.find(c=>c.mediaType+':'+c.id===el.dataset.externalCredit);if(credit)window.dispatchEvent(new CustomEvent('mediaorbit:add-requested',{detail:{mediaType:credit.mediaType,tmdbId:credit.id,title:credit.title,year:credit.year,poster:credit.poster}}));});box.querySelectorAll('[data-person-credit-section]').forEach(el=>el.ontoggle=()=>{const open=el.open;if(el.dataset.personCreditSection==='past'&&state.pastOpen!==open){state.pastOpen=open;renderPersonResults(person);}if(el.dataset.personCreditSection==='upcoming'&&state.upcomingOpen!==open){state.upcomingOpen=open;renderPersonResults(person);}}); } function creditsSorted(credits,mode=sortMode){return [...credits].sort((a,b)=>mode==='newest'?(b.releaseDate||b.firstAirDate||'').localeCompare(a.releaseDate||a.firstAirDate||''):(a.releaseDate||a.firstAirDate||'9999').localeCompare(b.releaseDate||b.firstAirDate||'9999'));} function externalCredits(credits,mode=sortMode,{latest=false}={}){return '
'+creditsSorted(credits,mode).map(c=>{const stored=matchCreditToLibrary(c,DATA),poster=safeURL(c.poster);return `
${poster?``:'▣'}
${esc(c.title||c.name)}${esc([c.year,c.mediaType==='tv'?'TV':'Movie',...(c.jobs||[]),...(c.characters||[])].filter(Boolean).join(' · '))}${stored.length?``:``}
`;}).join('')+'
';} function peopleCreditHTML(credits,mode=sortMode,{latest=null,pastOpen=false,upcomingOpen=false}={}){const today=new Date().toISOString().slice(0,10),latestKey=latest&&`${latest.mediaType}:${latest.id}`,visible=credits.filter(c=>`${c.mediaType}:${c.id}`!==latestKey),upcoming=visible.filter(c=>(c.releaseDate||c.firstAirDate||'')>today),past=visible.filter(c=>!(c.releaseDate||c.firstAirDate)||(c.releaseDate||c.firstAirDate)<=today);let html='';if(latest)html+='

LATEST

'+externalCredits([latest],mode,{latest:true});if(upcoming.length)html+=`
UPCOMING · ${upcoming.length}${upcomingOpen?externalCredits(upcoming,mode):''}
`;html+=`
PAST WORKS · ${past.length}${pastOpen?externalCredits(past,mode):''}
`;return html;} async function loadPeopleDetail(person){const id=person.tmdbId;if(peopleFlights.has(id))return peopleFlights.get(id);const flight=Promise.all([requestJSON('/api/media/person/'+id),requestJSON('/api/media/person/'+id+'/credits')]).then(([profile,credits])=>{personCache.set('profile:'+id,profile);personCache.set('credits:'+id,credits);refreshPeopleLayer();const current=peopleIndex.byId.get(person.id);if(current&&profile.person?.profile)current.profile=profile.person.profile;if(trail.at(-1)?.kind==='person'&&trail.at(-1).id===person.id){renderDetail();const n=graph?.byId.get(person.id);if(n&&profile.person?.profile){n.profile=profile.person.profile;const body=elements.get(n.id)?.querySelector('.node-body');if(body){body.replaceChildren();drawPersonBody(body,n);}}}}).catch(error=>{if(trail.at(-1)?.id===person.id&&$('peopleLoading')){$('peopleLoading').textContent=error.message;const button=document.createElement('button');button.textContent='もう一度取得';button.onclick=()=>void loadPeopleDetail(person);$('peopleLoading').append(document.createElement('br'),button);}}).finally(()=>peopleFlights.delete(id));peopleFlights.set(id,flight);return flight;} async function searchPersonCandidates(person){const button=$('searchPerson'),box=$('personCandidates');button.disabled=true;box.textContent='候補を検索しています…';try{const result=await requestJSON('/api/media/people/search?q='+encodeURIComponent($('personQuery')?.value.trim()||person.name));if(trail.at(-1)?.id!==person.id)return;box.replaceChildren();for(const candidate of result.people||[]){const el=document.createElement('button'),known=(candidate.knownFor||[]).map(work=>work.title+(work.year?' ('+work.year+')':'')).join(' / ');el.className='person-row';el.dataset.personId=String(candidate.id);el.innerHTML=avatar(candidate)+`${esc(candidate.name)}${esc([candidate.originalName!==candidate.name?candidate.originalName:'',candidate.knownForDepartment,'TMDb #'+candidate.id].filter(Boolean).join(' · '))}${known?`${esc(known)}`:''}`;el.onclick=()=>{resolvedPeople.set(person.id,candidate);persistResolvedPeople();const oldId=person.id;focusedPersonId=oldId;refreshPeopleLayer();const updated=peopleIndex.byId.get('person:tmdb:'+candidate.id);trail.pop();openPerson(updated);};box.append(el);}if(!box.children.length)box.textContent='候補が見つかりませんでした。英語名でも検索できます。';}catch(error){box.textContent=error.message;}finally{button.disabled=false;}} $('search').oninput=search;$('search').onkeydown=e=>{if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();if(!suggestions.length)return;suggestionIndex=(suggestionIndex+(e.key==='ArrowDown'?1:-1)+suggestions.length)%suggestions.length;drawSuggestionSelection();previewSuggestion();}else if(e.key==='Enter'&&suggestions.length){e.preventDefault();commitSuggestion(suggestions[suggestionIndex]);}else if(e.key==='Escape')setSearchOpen(false);}; $('filterToggle').onclick=()=>setFilterOpen($('filters').hidden);$('clearFilters').onclick=()=>{Object.values(filters).forEach(s=>s.clear());renderFilters();highlight();if(trail.at(-1)?.kind==='filtered')closeDrawer();fit();}; $('connectionsToggle').onclick=()=>setConnectionsOpen($('connectionsMenu').hidden);$('workConnections').onchange=()=>{connections=$('workConnections').checked;highlight();};$('connectionPeopleSearch').oninput=renderConnectionPeopleResults;$('clearPeople').onclick=()=>{peopleControls=createPeopleControlsState();connectionRoles=peopleControls.roles;closeDrawer();persistPeopleControls();renderConnectionOptions();frameSelectedPeople();};$('retryPeopleSync').onclick=()=>peopleSync.retry();$('searchOpen').onclick=()=>setSearchOpen($('searchBox').hidden);$('searchClose').onclick=()=>setSearchOpen(false);renderConnectionOptions(); $('driftToggle').onclick=()=>{drift=!drift;$('driftToggle').setAttribute('aria-pressed',String(drift));if(drift)warm(1);};$('home').onclick=()=>appOptions.onHome?.(); document.addEventListener('pointerdown',e=>{if(!e.target.closest('.filter-wrap,#filters,#workspace'))setFilterOpen(false);if(!e.target.closest('.search-wrap')){hideSuggestions();if(!$('search').value)setSearchOpen(false,{resetFocus:false});}if(!e.target.closest('.connections-wrap'))setConnectionsOpen(false);});document.addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setSearchOpen(true);}if(e.key==='Escape'){setSearchOpen(false);setConnectionsOpen(false);setFilterOpen(false);closeDrawer();}}); $('graph').onpointerdown=e=>{if(!graph||currentFormat===null||e.button!==0||['root','format'].includes(graph.byId.get(e.target.closest('.orbit-node')?.dataset.nodeId)?.kind))return;cancelAnimationFrame(animation);delete $('graph').dataset.traveling;const n=e.target.closest('.orbit-node');drag={pointerId:e.pointerId,startX:e.clientX,startY:e.clientY,x:e.clientX,y:e.clientY,camera:{...camera},nodeId:n?.dataset.nodeId,moved:false};};$('graph').onpointermove=e=>{if(!drag||!graph)return;const r=fieldSize,dx=e.clientX-drag.x,dy=e.clientY-drag.y,wasMoved=drag.moved;drag.moved||=Math.hypot(e.clientX-drag.startX,e.clientY-drag.startY)>5;if(!drag.moved)return;if(!wasMoved)clearTransientHover();overview=false;hidePosterHint();if(!$('graph').hasPointerCapture(e.pointerId))$('graph').setPointerCapture(e.pointerId);selectionBeforeDrag=true;if(drag.nodeId){const n=graph.byId.get(drag.nodeId);n.x+=dx*camera.width/r.width;n.y+=dy*camera.width/r.width;n.ax=n.x;n.ay=n.y;if(focusOrigins.has(n.id))focusOrigins.set(n.id,{x:n.x,y:n.y});dragFrames.request();}else{camera.x-=dx*camera.width/r.width;camera.y-=dy*camera.width/r.width;requestCamera();}drag.x=e.clientX;drag.y=e.clientY;};$('graph').onpointerup=$('graph').onpointercancel=()=>{const moved=drag?.moved,nodeId=drag?.nodeId;if(drag&&!moved&&!nodeId)closeDrawer();drag=null;if(moved&&nodeId)warm(35);setTimeout(()=>selectionBeforeDrag=false,0);};$('graph').addEventListener('wheel',e=>{if(!graph||currentFormat===null)return;e.preventDefault();overview=false;hidePosterHint();cancelAnimationFrame(animation);delete $('graph').dataset.traveling;camera=zoomCamera(camera,fieldSize,e);requestCamera();},{passive:false}); const fieldObserver=new ResizeObserver(()=>{measureField();if(graph){if(overview)fit();else{const n=graph.byId.get(activeId);if(n?.kind==='work'&&trail.at(-1)?.kind==='work'&&!$('graph').dataset.traveling)camera.width=workFrameWidth(n);applyCamera();}}});fieldObserver.observe($('field')); window.addEventListener('mediaorbit:sync-status',e=>{const status=$('syncStatus');status.dataset.state=e.detail.state;status.title=e.detail.message;peopleSyncReady=e.detail.state==='ready'&&Boolean(e.detail.message?.startsWith('NOTION SYNC'));peopleSync.setEnabled(peopleSyncReady&¤tFormat!==null);}); window.addEventListener('mediaorbit:library-updated',e=>{DATA=e.detail.data;fullUpdatedAt=e.detail.authenticated===false?0:e.detail.updatedAt;for(const [id,work]of confirmedSaves){if(!DATA.w.some(row=>pageId(row[0])===id))continue;DATA.workDetails??={};DATA.workDetails[id]={...work,...DATA.workDetails[id]};}indexLibrary();appOptions.onSummary?.(e.detail.authenticated===false?{works:null,unauthorized:true,error:'ログインしてください。Refreshで再確認できます。'}:compactUniversePayload(DATA,fullUpdatedAt));if(currentFormat!==null)rebuild();peopleSync.reconcile(currentFormat===null?[]:[...formatWorkIds(DATA,currentFormat)]);renderHomeWorlds();if(trail.length)renderDetail();}); window.addEventListener('mediaorbit:work-saved',e=>{toast('Notionに保存しました。マップを更新しています…');if(e.detail.work)confirmedSaves.set(pageId(e.detail.pageId),e.detail.work);if(e.detail.worldId&&e.detail.work)void worldView.attachSavedWork(e.detail.worldId,e.detail.work).catch(error=>toast(error.message));}); for(const event of ['mediaorbit:details-updated','mediaorbit:relations-updated'])window.addEventListener(event,e=>{if(DATA&&searchGraph)reindexPeople();refreshPeopleLayer();renderFilters();if(trail.at(-1)?.kind==='world')void worldView.refreshActive();else if(trail.at(-1)?.kind==='person')renderPersonResults(peopleIndex.byId.get(trail.at(-1).id)||trail.at(-1).person);else if(trail.at(-1)?.kind==='work'&&trail.at(-1).id===e.detail.workId){renderDetail();highlight();}}); // Form return and core refresh belong to the shared shell, including Home. document.addEventListener('visibilitychange',()=>{if(!document.hidden&&graph){measureField();applyCamera();if(drift)warm(1);}}); function indexLibrary(){ worksByPageId=indexWorksByPageId(DATA.w);searchGraph=buildOrbitGraph(DATA,{directors:true,authors:true,series:true});syncWorldMetadata(); for(const n of searchGraph.nodes)if(n.kind==='work')n.year=Number(workForNode(n)?.[3])||undefined; reindexPeople(); } function cancelSceneJobs(){ cameraFrames.cancel();dragFrames.cancel();deferredCameraPaint.cancel();cancelAnimationFrame(animation);animation=null; cancelAnimationFrame(simulation);simulation=null;layoutEngine?.stop();layoutEngine=null;energy=0;stopCameraCull(); clearTimeout(peoplePaintTimer);peoplePaintTimer=null;hideSuggestions(); const el=$('graph');if(drag?.pointerId!=null&&el.hasPointerCapture(drag.pointerId))el.releasePointerCapture(drag.pointerId); drag=null;selectionBeforeDrag=false;cameraTransforming=false;committedCamera=null;committedSize=null; el.style.transform='';el.classList.remove('camera-transforming');delete el.dataset.traveling;delete el.dataset.interacting; } function saveScene(){if(graph&¤tFormat!==null){restoreFocusAnchors();sceneSnapshots.set(currentFormat,{nodes:new Map(graph.nodes.filter(n=>n.kind!=='person').map(n=>[n.id,Object.fromEntries(['id','kind','pageId','workIds','x','y','ax','ay','rotation','width','height','posterWidth','posterHeight'].map(k=>[k,n[k]]))])),camera:{...camera},overview});}} function activateFormat(format,{keepDetail=false}={}){ if(currentFormat===format&&graph)return; saveScene();cancelSceneJobs();graph=null;currentFormat=null;if(!keepDetail)closeDrawer();currentFormat=format; Object.values(filters).forEach(set=>set.clear());activeId=null;hoverId=null;focusedPersonId=null; const saved=sceneSnapshots.get(format);camera=saved?{...saved.camera}:{x:0,y:0,width:4000};overview=saved?.overview??true; rebuild({preserve:Boolean(saved)});if(saved&&overview)fit(); peopleSync.reconcile([...formatWorkIds(DATA,currentFormat)]);peopleSync.setEnabled(peopleSyncReady);$('mapHint').textContent=Number($('field').dataset.workCount)?'作品を選択して、人物とつながりを探索':(format||'UNSORTED')+' の保存済み作品はありません。DISCOVERから追加できます。'; } export function suspendOrbit({keepDetail=false}={}){ saveScene();cancelSceneJobs();currentFormat=null;graph=null;if(!keepDetail)closeDrawer();focusId=null;focusOrigins.clear(); elements.clear();edgeElements.length=0;cameraNodes=[];for(const id of ['nodes','links','orbits'])$(id).replaceChildren(); $('filterGroups').replaceChildren();$('filterResults').replaceChildren();$('connectionPeopleResults').replaceChildren();$('selectedPeople').replaceChildren(); $('field').dataset.workCount='0';peopleSync.setEnabled(false);$('peopleSyncStatus').hidden=true; } export async function initializeOrbitApp(options){appOptions=options;await ensureLibrary();} async function ensureLibrary(){ if(libraryFlight)return libraryFlight;if(DATA)return DATA;libraryNeeded=true; libraryFlight=(async()=>{ const response=await fetch('/library-map-seed.c480b7045484.json.gz');if(!response.ok)throw new Error('ライブラリー設定を取得できませんでした。'); const baseline=await new Response(response.body.pipeThrough(new DecompressionStream('gzip'))).json(); // The baseline contributes stable membership/readings, never a pretend live count. const empty={...baseline,s:[],i:[...baseline.i]}; DATA=await loadLibrary(empty,{managed:true,emptyOnAuthFailure:true,isActive:()=>libraryNeeded||currentFormat!==null||!$('drawer').hidden||!$('worldsMenu').hidden,onController:controller=>libraryController=controller}); if(!fullUpdatedAt)throw new Error($('syncStatus').textContent||'Notionと同期できませんでした。');return DATA; })().catch(error=>{DATA=null;libraryFlight=null;throw error;}).finally(()=>libraryNeeded=false); return libraryFlight; } export async function openFormat(format){await ensureLibrary();if(format!==''&&!DATA.f.includes(format)&&!['Movie','Anime','Anime Movie','Drama'].includes(format)){appOptions.onHome?.();return;}activateFormat(format);} export async function refreshFullLibrary(force=false){await ensureLibrary();const result=await libraryController?.refresh(force);if(!fullUpdatedAt)throw new Error('ログインしてください。Refreshで再確認できます。');return result;} export function libraryUpdatedAt(){return fullUpdatedAt;} export async function openSavedWork(id){ await ensureLibrary();const row=worksByPageId.get(pageId(id));if(!row)return;const format=DATA.f[row[2]]||''; appOptions.onOpenFormat?.(format,{alreadyActivated:true});activateFormat(format,{keepDetail:true}); const n=graph.byId.get('work:'+row[0]);if(n)selectNode(n); } export async function followSavedWork(id){await ensureLibrary();return focusSavedWork(id);} export async function openHomeWorlds(){await ensureLibrary();$('worldsMenu').hidden=false;renderHomeWorlds();await loadWorldList();renderHomeWorlds();} function renderHomeWorlds(){ const box=$('worldsMenu');if(box.hidden||!searchGraph)return; box.innerHTML='
MY WORLDS

保存済み作品と、まだ保存していない作品を探す

'; $('closeWorlds').onclick=()=>box.hidden=true;$('createHomeWorld').onclick=()=>{box.hidden=true;openCreateWorld();}; for(const n of worldNodes().sort((a,b)=>a.label.localeCompare(b.label,'ja'))){const b=document.createElement('button');b.className='home-world-row';b.innerHTML=''+esc(n.label)+''+n.workIds.length+' SAVED ↗';b.onclick=()=>{box.hidden=true;void openWorld(n);};$('homeWorldRows').append(b);} if(worldListError){const p=document.createElement('p');p.className='muted';p.textContent=worldListError;box.append(p);} }