@@ -21,6 +21,7 @@
"poo" : "Poo" ,
"weight" : "Weigh-in" ,
"training" : "Training" ,
"note" : "Note" ,
} ;
// ---------- photos: IndexedDB store ----------
@@ -230,6 +231,28 @@
localStorage . setItem ( configKey ( ) , JSON . stringify ( cfg ) ) ;
}
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
// never loses to an empty one — so it can't be dropped by a clock race between
// devices — and when both are set the newer profile's id wins with the rest.
// (The server merge mirrors this, so a set id is only changed, never cleared,
// by sync.)
function reconcileConfig ( local , server ) {
const base = server . updatedAt >= local . updatedAt ? server : local ;
return {
name : base . name ,
birthday : base . birthday ,
pedigreeId : ( local . pedigreeId && server . pedigreeId )
? base . pedigreeId
: ( local . pedigreeId || server . pedigreeId ) ,
updatedAt : base . updatedAt ,
} ;
}
function sameConfig ( a , b ) {
return a . name === b . name && a . birthday === b . birthday
&& a . pedigreeId === b . pedigreeId && a . updatedAt === b . updatedAt ;
}
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
// at that point). Returns null for a missing/invalid birthday or a date before it.
@@ -269,6 +292,24 @@
return ` ${ wk } ( ${ monthPhrase } ) old ` ;
}
// Compact one-line age for space-tight spots (header): keeps the weeks + months
// breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk".
function formatAgeShort ( birthday , at ) {
const a = ageParts ( birthday , at ) ;
if ( ! a ) return "" ;
const wk = ` ${ a . weeks } wk ` ;
if ( a . months < 1 ) return wk ;
const mo = ` ${ a . months } mo ${ a . remWeeks > 0 ? ` ${ a . remWeeks } wk ` : "" } ` ;
if ( a . months >= 4 ) return mo ;
return ` ${ wk } · ${ mo } ` ;
}
// Weeks-only age for dense lists (weight rows): "16 wk".
function formatAgeWeeks ( birthday , at ) {
const a = ageParts ( birthday , at ) ;
return a ? ` ${ a . weeks } wk ` : "" ;
}
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
// 0– 8 weeks 20– 22h, 8– 16 weeks 18– 20h, then 16– 18h to 6 months and 14– 16h to
// 12 months. No birthday (or an adult dog) → no goal.
@@ -854,6 +895,53 @@
}
}
// Cross-day log of free-text notes (vaccinations, vet visits, milestones…),
// newest first. Unlike History this ignores the day picker so the record is
// always visible regardless of which day you're viewing.
function renderNotes ( events ) {
const notes = events
. filter ( e => e . type === "note" )
. sort ( ( a , b ) => b . at - a . at ) ;
const list = document . getElementById ( "notes-list" ) ;
const empty = document . getElementById ( "notes-empty" ) ;
list . innerHTML = "" ;
if ( notes . length === 0 ) { empty . hidden = false ; return ; }
empty . hidden = true ;
const birthday = loadConfig ( ) . birthday ;
for ( const ev of notes ) {
const li = document . createElement ( "li" ) ;
li . className = "event" ;
li . dataset . type = "note" ;
li . dataset . id = ev . id ;
const dateStr = new Date ( ev . at ) . toLocaleDateString ( undefined , { year : "numeric" , month : "short" , day : "numeric" } ) ;
const age = formatAgeWeeks ( birthday , ev . at ) ;
li . innerHTML = `
<span class="dot"></span>
<span class="note-date"> ${ escapeText ( age ? ` ${ dateStr } · ${ age } ` : dateStr ) } </span>
<span class="note-text"></span>
` ;
li . querySelector ( ".note-text" ) . textContent = ev . note || "" ;
li . addEventListener ( "click" , ( ) => openEditDialog ( ev ) ) ;
for ( const pid of photoIdsOf ( ev ) ) {
const img = document . createElement ( "img" ) ;
img . className = "thumb" ;
img . alt = "photo" ;
img . loading = "lazy" ;
img . dataset . photoId = pid ;
img . addEventListener ( "click" , ( e ) => {
e . stopPropagation ( ) ;
openLightbox ( pid ) ;
} ) ;
li . appendChild ( img ) ;
photoSrc ( pid ) . then ( url => { if ( url ) img . src = url ; } ) ;
}
list . appendChild ( li ) ;
}
}
// ---------- lightbox ----------
const lightbox = document . getElementById ( "lightbox" ) ;
const lightboxImg = document . getElementById ( "lightbox-img" ) ;
@@ -887,6 +975,27 @@
render ( ) ;
}
// Which metrics the daily-counts chart shows. Device-global, toggled via the
// checkboxes under the chart. At least one is always kept on so the chart is
// never empty; an invalid/empty stored value falls back to all three.
const COUNTS _METRICS _KEY = "puppy-tracker:counts-metrics:v1" ;
const COUNTS _METRIC _KEYS = [ "pees" , "poos" , "meals" ] ;
function countsMetrics ( ) {
let stored ;
try { stored = JSON . parse ( localStorage . getItem ( COUNTS _METRICS _KEY ) ) ; } catch { /* ignore */ }
const on = Array . isArray ( stored )
? COUNTS _METRIC _KEYS . filter ( k => stored . includes ( k ) )
: [ ] ;
return on . length ? on : COUNTS _METRIC _KEYS . slice ( ) ;
}
function setCountsMetrics ( keys ) {
// Never let the user hide everything — keep at least one metric visible.
const on = COUNTS _METRIC _KEYS . filter ( k => keys . includes ( k ) ) ;
if ( ! on . length ) { renderChartWindow ( ) ; return ; } // restore the checkbox we just rejected
try { localStorage . setItem ( COUNTS _METRICS _KEY , JSON . stringify ( on ) ) ; } catch { /* ignore */ }
render ( ) ;
}
// Sync every "(last N days)" header and the picker's active button.
function renderChartWindow ( ) {
const n = chartDays ( ) ;
@@ -898,6 +1007,10 @@
document . querySelectorAll ( ".chart-days-picker button" ) . forEach ( b => {
b . classList . toggle ( "active" , Number ( b . dataset . days ) === n ) ;
} ) ;
const on = countsMetrics ( ) ;
document . querySelectorAll ( "#counts-metrics input[data-metric]" ) . forEach ( cb => {
cb . checked = on . includes ( cb . dataset . metric ) ;
} ) ;
}
// ---------- daily charts ----------
@@ -1054,13 +1167,19 @@
const innerW = W - ML - MR ;
const innerH = H - MT - MB ;
const rawMax = Math . max ( ... days . flatMap ( d => [ d . pees , d . poos , d . meals ] ) ) ;
const series = [
{ key : "pees" , label : "Pees" , cls : "bar-pee" } ,
{ key : "poos" , label : "Poos" , cls : "bar-poo" } ,
{ key : "meals" , label : "Meals" , cls : "bar-eat" } ,
] . filter ( s => countsMetrics ( ) . includes ( s . key ) ) ;
const rawMax = Math . max ( 0 , ... days . flatMap ( d => series . map ( s => d [ s . key ] ) ) ) ;
const { yMax , steps : ySteps } = niceAxis ( rawMax ) ;
const groupGap = days . length > 14 ? 2 : 4 ;
const innerBarGap = days . length > 14 ? 0.5 : 1.5 ;
const groupW = ( innerW - ( days . length - 1 ) * groupGap ) / days . length ;
const barW = ( groupW - 2 * innerBarGap ) / 3 ;
const barW = ( groupW - ( series . length - 1 ) * innerBarGap ) / series . length ;
const parts = [ ] ;
for ( let i = 0 ; i <= ySteps ; i ++ ) {
@@ -1070,12 +1189,6 @@
parts . push ( ` <text x=" ${ ML - 4 } " y=" ${ y + 3 } " text-anchor="end"> ${ v } </text> ` ) ;
}
const series = [
{ key : "pees" , label : "Pees" , cls : "bar-pee" } ,
{ key : "poos" , label : "Poos" , cls : "bar-poo" } ,
{ key : "meals" , label : "Meals" , cls : "bar-eat" } ,
] ;
const selYmd = ymd ( selectedDay ( ) ) ;
days . forEach ( ( d , i ) => {
const isToday = i === days . length - 1 ;
@@ -1647,7 +1760,7 @@
li . className = "ww weight-ww" ;
const date = document . createElement ( "span" ) ;
date . className = "ww-range" ;
const age = formatAge ( birthday , w . at ) ;
const age = formatAgeWeeks ( birthday , w . at ) ;
const dateStr = new Date ( w . at ) . toLocaleDateString ( undefined , { month : "short" , day : "numeric" } ) ;
date . textContent = age ? ` ${ dateStr } · ${ age } ` : dateStr ;
const val = document . createElement ( "span" ) ;
@@ -1866,7 +1979,7 @@
const ageEl = document . getElementById ( "puppy-age" ) ;
title . textContent = cfg . name ? ` 🐶 ${ cfg . name } ` : "🐶 Puppy Tracker" ;
document . title = cfg . name ? ` ${ cfg . name } · Puppy Tracker ` : "Puppy Tracker" ;
const ageText = formatAge ( cfg . birthday ) ;
const ageText = formatAgeShort ( cfg . birthday ) ;
ageEl . textContent = ageText ;
ageEl . hidden = ! ageText ;
// Use the puppy's name in the sleep-timeline heading rather than assuming a
@@ -1908,6 +2021,7 @@
renderHourHeatmap ( events ) ;
renderTraining ( events ) ;
renderWeight ( events ) ;
renderNotes ( events ) ;
renderHistory ( events ) ;
}
@@ -2040,12 +2154,14 @@
pedigreeId : body . pedigreeId || "" ,
updatedAt : Number . isFinite ( body . updatedAt ) ? body . updatedAt : 0 ,
} ;
if ( server . updatedAt > local . updatedAt ) {
saveConfig ( server ) ;
const merged = reconcileConfig ( local , server ) ;
if ( ! sameConfig ( merged , local ) ) {
saveConfig ( merged ) ;
renderHeader ( ) ;
refreshPedigreeButton ( ) ;
} else if ( local . updatedAt > server . updatedAt ) {
await pushConfig ( local ) ;
}
if ( ! sameConfig ( merged , server ) ) {
await pushConfig ( merged ) ;
}
} catch ( err ) {
console . warn ( "config sync failed:" , err ) ;
@@ -2137,11 +2253,21 @@
function openNoteDialog ( type ) {
pendingType = type ;
noteInput . value = "" ;
noteTimeEdited = false ;
const now = Date . now ( ) ;
noteDate . value = toDateInput ( now ) ;
noteTime . value = toTimeInput ( now ) ;
noteTitle . textContent = ` Log ${ EVENT _LABELS [ type ] } ` ;
const isNote = type === "note" ;
// A note is about a day, so default it to the day you're viewing (at the
// current clock time). The logging types default to "now"; leaving
// noteTimeEdited false lets noteDialogAt() stamp the exact instant.
let base = Date . now ( ) ;
if ( isNote ) {
const day = selectedDay ( ) ;
const now = new Date ( ) ;
day . setHours ( now . getHours ( ) , now . getMinutes ( ) , 0 , 0 ) ;
base = day . getTime ( ) ;
}
noteTimeEdited = isNote ;
noteDate . value = toDateInput ( base ) ;
noteTime . value = toTimeInput ( base ) ;
noteTitle . textContent = isNote ? "Add note" : ` Log ${ EVENT _LABELS [ type ] } ` ;
const isWeight = type === "weight" ;
const isEat = type === "eat" ;
noteWeightField . hidden = ! isWeight ;
@@ -2191,6 +2317,10 @@
document . getElementById ( "note-save" ) . addEventListener ( "click" , async ( e ) => {
e . preventDefault ( ) ;
if ( ! pendingType ) { noteDialog . close ( ) ; return ; }
if ( pendingType === "note" && noteInput . value . trim ( ) === "" && notePhotos . length === 0 ) {
alert ( "Write something for the note, or add a photo." ) ;
return ;
}
let weight ;
if ( pendingType === "weight" ) {
weight = parseFloat ( noteWeight . value ) ;
@@ -2380,6 +2510,7 @@
const settingsBirthday = document . getElementById ( "settings-birthday" ) ;
const settingsPedigree = document . getElementById ( "settings-pedigree" ) ;
const settingsTheme = document . getElementById ( "settings-theme" ) ;
const settingsConfetti = document . getElementById ( "settings-confetti" ) ;
// Apply live so the toggle previews immediately (independent of Save/Cancel).
settingsTheme . addEventListener ( "change" , ( ) => {
@@ -2392,6 +2523,7 @@
settingsBirthday . value = cfg . birthday ;
settingsPedigree . value = cfg . pedigreeId ;
settingsTheme . checked = effectiveTheme ( ) === "dark" ;
settingsConfetti . checked = confettiEnabled ( ) ;
settingsDialog . showModal ( ) ;
setTimeout ( ( ) => settingsName . focus ( ) , 50 ) ;
}
@@ -2407,6 +2539,7 @@
updatedAt : Date . now ( ) ,
} ;
saveConfig ( cfg ) ; // cache locally for instant + offline paint
setConfettiEnabled ( settingsConfetti . checked ) ; // device-local, not synced
renderHeader ( ) ;
refreshPedigreeButton ( ) ;
settingsDialog . close ( ) ;
@@ -2434,11 +2567,89 @@
const pedTree = document . getElementById ( "pedigree-tree" ) ;
const pedBtn = document . getElementById ( "pedigree-btn" ) ;
const pedRefresh = document . getElementById ( "pedigree-refresh" ) ;
const pedRepeatNote = document . getElementById ( "pedigree-repeat-note" ) ;
const PED _OPEN _DEPTH = 4 ; // generations shown expanded by default; deeper collapse
const PED _OPEN _DEPTH = 3 ; // show 3 generations expanded by default; deeper collapses
let pedPollTimer = null ;
let pedNodes = { } ; // latest ancestry map, for the progress count
// ---- zoom ----
// The tree gets very wide when expanded, so it's zoomable: buttons, ctrl/⌘ +
// wheel, and pinch. We scale via the CSS `zoom` property (not transform) so the
// scroll container reflows and every part stays reachable. The level persists.
const pedZoomOut = document . getElementById ( "ped-zoom-out" ) ;
const pedZoomIn = document . getElementById ( "ped-zoom-in" ) ;
const pedZoomReset = document . getElementById ( "ped-zoom-reset" ) ;
const PED _ZOOM _MIN = 0.4 , PED _ZOOM _MAX = 1.6 ;
const pedZoomKey = ( ) => ` puppy-tracker: ${ currentUser . id } :pedigree-zoom:v1 ` ;
let pedZoom = 1 ;
function applyPedZoom ( ) {
pedZoom = Math . min ( PED _ZOOM _MAX , Math . max ( PED _ZOOM _MIN , Math . round ( pedZoom * 100 ) / 100 ) ) ;
pedTree . style . zoom = pedZoom ;
pedZoomReset . textContent = Math . round ( pedZoom * 100 ) + "%" ;
try { localStorage . setItem ( pedZoomKey ( ) , String ( pedZoom ) ) ; } catch { /* ignore */ }
}
function pedZoomBy ( delta ) { pedZoom += delta ; applyPedZoom ( ) ; }
pedZoomIn . addEventListener ( "click" , ( ) => pedZoomBy ( 0.2 ) ) ;
pedZoomOut . addEventListener ( "click" , ( ) => pedZoomBy ( - 0.2 ) ) ;
pedZoomReset . addEventListener ( "click" , ( ) => { pedZoom = 1 ; applyPedZoom ( ) ; } ) ;
// ---- collapse / expand all ----
const pedFoldAll = document . getElementById ( "ped-foldall" ) ;
function pedSetAll ( collapsed ) {
pedTree . querySelectorAll ( "li" ) . forEach ( ( li ) => {
if ( ! li . querySelector ( ":scope > ul" ) ) return ; // no ancestors to fold
li . classList . toggle ( "collapsed" , collapsed ) ;
const t = li . querySelector ( ":scope > .ped-card > .ped-toggle" ) ;
if ( t ) t . textContent = collapsed ? "+" : "− " ;
} ) ;
pedFoldAll . textContent = collapsed ? "Expand all" : "Collapse all" ;
}
pedFoldAll . addEventListener ( "click" , ( ) => pedSetAll ( pedFoldAll . textContent [ 0 ] === "C" ) ) ;
// ---- view toggle: top-down tree vs radial fan ----
const pedViewBtn = document . getElementById ( "ped-view" ) ;
const pedCaption = document . getElementById ( "pedigree-caption" ) ;
const pedViewKey = ( ) => ` puppy-tracker: ${ currentUser . id } :pedigree-view:v1 ` ;
let pedView = "tree" ;
try { const v = localStorage . getItem ( pedViewKey ( ) ) ; if ( v === "fan" || v === "tree" ) pedView = v ; } catch { /* ignore */ }
// The fan has no per-branch folding, so hide that control in fan mode; the
// toggle always offers the *other* view.
function updatePedViewControls ( ) {
pedViewBtn . textContent = pedView === "fan" ? "Tree view" : "Fan view" ;
pedFoldAll . hidden = pedView === "fan" ;
if ( pedView !== "fan" && pedCaption ) { pedCaption . hidden = true ; }
}
pedViewBtn . addEventListener ( "click" , ( ) => {
pedView = pedView === "fan" ? "tree" : "fan" ;
try { localStorage . setItem ( pedViewKey ( ) , pedView ) ; } catch { /* ignore */ }
renderPedigree ( pedNodes ) ;
} ) ;
// Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page.
pedTree . addEventListener ( "wheel" , ( e ) => {
if ( ! e . ctrlKey && ! e . metaKey ) return ;
e . preventDefault ( ) ;
pedZoomBy ( e . deltaY < 0 ? 0.1 : - 0.1 ) ;
} , { passive : false } ) ;
// Touch: two-finger pinch.
let pinchDist = 0 , pinchZoom = 1 ;
const touchDist = ( t ) => Math . hypot ( t [ 0 ] . clientX - t [ 1 ] . clientX , t [ 0 ] . clientY - t [ 1 ] . clientY ) ;
pedTree . addEventListener ( "touchstart" , ( e ) => {
if ( e . touches . length === 2 ) { pinchDist = touchDist ( e . touches ) ; pinchZoom = pedZoom ; }
} , { passive : true } ) ;
pedTree . addEventListener ( "touchmove" , ( e ) => {
if ( e . touches . length === 2 && pinchDist > 0 ) {
e . preventDefault ( ) ;
pedZoom = pinchZoom * ( touchDist ( e . touches ) / pinchDist ) ;
applyPedZoom ( ) ;
}
} , { passive : false } ) ;
pedTree . addEventListener ( "touchend" , ( e ) => { if ( e . touches . length < 2 ) pinchDist = 0 ; } ) ;
// The looked-up tree is cached locally per dog id, so reopening the page paints
// instantly and still shows the last-known tree offline. The server caches it
// too (per dog, permanently); this is just the client-side mirror.
@@ -2459,6 +2670,9 @@
const id = loadConfig ( ) . pedigreeId ;
appEl . hidden = true ;
pedScreen . hidden = false ;
const saved = parseFloat ( localStorage . getItem ( pedZoomKey ( ) ) ) ;
pedZoom = Number . isFinite ( saved ) ? saved : 1 ;
applyPedZoom ( ) ;
if ( ! id ) { setPedStatus ( "Set your dog's SKK id in Settings to see its pedigree." , "" ) ; return ; }
lookupPedigree ( id ) ;
}
@@ -2489,7 +2703,7 @@
const cached = loadPedCache ( q ) ;
if ( cached && cached . nodes ) {
renderSubject ( cached . subject ) ;
renderTree ( cached . nodes ) ;
renderPedigree ( cached . nodes ) ;
}
if ( ! navigator . onLine ) {
setPedStatus ( cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection." ,
@@ -2522,7 +2736,7 @@
const data = await res . json ( ) ;
if ( data . status === "choose" ) { renderChoose ( data . matches || [ ] ) ; return ; }
renderSubject ( data . subject ) ;
renderTree ( data . nodes || { } ) ;
renderPedigree ( data . nodes || { } ) ;
if ( data . status === "done" ) {
savePedCache ( q , data . subject , data . nodes || { } ) ;
setPedDone ( ) ;
@@ -2541,7 +2755,7 @@
if ( res . status === 401 ) { handleLoggedOut ( ) ; return ; }
if ( ! res . ok ) { setPedStatus ( "Lost track of the pedigree crawl." , "error" ) ; return ; }
const data = await res . json ( ) ;
renderTree ( data . nodes || { } ) ;
renderPedigree ( data . nodes || { } ) ;
if ( data . status === "done" ) { savePedCache ( q , subject , data . nodes || { } ) ; setPedDone ( ) ; return ; }
if ( data . status === "error" ) { setPedStatus ( data . error || "Pedigree crawl failed." , "error" ) ; return ; }
setPedStatus ( ` Tracing ancestry… ${ pedCountText ( ) } ` , "busy" ) ;
@@ -2556,20 +2770,24 @@
// fills many positions); "generations back" is the depth of the deepest
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
function pedCounts ( ) {
const seen = new Set ( ) ;
const counts = new Map ( ) ;
let maxPos = 1 ;
for ( const k in pedNodes ) {
const n = pedNodes [ k ] ;
const key = n . reg || n . name ;
if ( key ) seen . add ( key ) ;
if ( key ) counts . set ( key , ( counts . get ( key ) || 0 ) + 1 ) ;
const p = Number ( k ) ;
if ( p > maxPos ) maxPos = p ;
}
return { distinct : seen . size , gens : Math . floor ( Math . log2 ( maxPos ) ) } ;
let repeated = 0 ;
counts . forEach ( ( c ) => { if ( c > 1 ) repeated ++ ; } ) ;
return { distinct : counts . size , repeated , gens : Math . floor ( Math . log2 ( maxPos ) ) } ;
}
function pedCountText ( ) {
const { distinct : a , gens : g } = pedCounts ( ) ;
return ` ${ a } ancestor ${ a === 1 ? "" : "s" } back ${ g } generation ${ g === 1 ? "" : "s" } ` ;
const { distinct : a , repeated : r , gens : g } = pedCounts ( ) ;
let s = ` ${ a } ancestor ${ a === 1 ? "" : "s" } back ${ g } generation ${ g === 1 ? "" : "s" } ` ;
if ( r > 0 ) s += ` , ${ r } appearing more than once ` ;
return s ;
}
function setPedDone ( ) { setPedStatus ( ` Traced ${ pedCountText ( ) } . ` , "done" ) ; }
function setPedStatus ( text , kind ) {
@@ -2622,12 +2840,192 @@
}
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
// 2n+1. We build recursively (sire above dam) and collapse below PED_OPEN_DEPTH.
function renderTree ( nodes ) {
// 2n+1. We render it top-down like a family tree — the dog on top, parents
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
// A dog's identity for spotting pedigree collapse: its registration number,
// or its name when it has none. Ancestors sharing a key are the same dog.
function dogKey ( n ) { return n ? ( n . reg || n . name || "" ) : "" ; }
// Stable hue per repeated dog so its badge/highlight colour is consistent
// everywhere it appears.
function hueFor ( s ) {
let h = 0 ;
for ( let i = 0 ; i < s . length ; i ++ ) h = ( h * 31 + s . charCodeAt ( i ) ) >>> 0 ;
return h % 360 ;
}
let pedRepeat = { } ; // dogKey -> occurrence count, for the current tree
// Render the ancestry in whichever view is active (top-down tree or radial
// fan). Shared prep — cache the nodes and count pedigree collapse — lives here.
function renderPedigree ( nodes ) {
pedNodes = nodes ;
pedRepeat = { } ;
for ( const k in nodes ) {
const id = dogKey ( nodes [ k ] ) ;
if ( id ) pedRepeat [ id ] = ( pedRepeat [ id ] || 0 ) + 1 ;
}
if ( pedRepeatNote ) pedRepeatNote . hidden = ! Object . values ( pedRepeat ) . some ( ( c ) => c > 1 ) ;
pedTree . textContent = "" ;
if ( pedView === "fan" ) renderFan ( nodes ) ; else renderTree ( nodes ) ;
updatePedViewControls ( ) ;
}
function renderTree ( nodes ) {
const root = buildPedNode ( nodes , 1 , 0 ) ;
if ( root ) pedTree . append ( root ) ;
if ( ! root ) return ;
const ul = document . createElement ( "ul" ) ;
ul . className = "ped-tree-h" ;
ul . append ( root ) ;
pedTree . append ( ul ) ;
if ( pedFoldAll ) pedFoldAll . textContent = "Collapse all" ; // fresh tree starts partly open
}
// ---- radial fan chart ----
// The dog sits in a centre disc; each generation is a ring fanning outward.
// A position p is at generation g = floor(log2 p); within that ring it takes
// the wedge (idx=p-2^g) of 2^g equal slices, which nests each dog's parents
// directly outside it. Labels only fit on the inner rings; deeper wedges are
// colour only, with details on tap. Repeated dogs (pedigree collapse) carry
// their stable hue so tapping one lights up every wedge of that dog.
const FAN _MAX _GEN = 9 ;
const SVGNS = "http://www.w3.org/2000/svg" ;
const fanPolar = ( r , deg ) => {
const a = ( deg - 90 ) * Math . PI / 180 ;
return [ r * Math . cos ( a ) , r * Math . sin ( a ) ] ;
} ;
const fanNum = ( n ) => Math . round ( n * 100 ) / 100 ;
function renderFan ( nodes ) {
let maxGen = 0 ;
for ( const k in nodes ) { const g = Math . floor ( Math . log2 ( Number ( k ) ) ) ; if ( g > maxGen ) maxGen = g ; }
maxGen = Math . min ( maxGen , FAN _MAX _GEN ) ;
const r0 = 46 ;
const radii = [ r0 ] ;
for ( let g = 1 ; g <= maxGen ; g ++ ) radii [ g ] = radii [ g - 1 ] + Math . max ( 24 , 50 - g * 3 ) ;
const R = radii [ maxGen ] || r0 ;
const pad = 4 ;
const box = ( R + pad ) * 2 ;
const svg = document . createElementNS ( SVGNS , "svg" ) ;
svg . setAttribute ( "class" , "ped-fan" ) ;
svg . setAttribute ( "viewBox" , ` ${ - R - pad } ${ - R - pad } ${ box } ${ box } ` ) ;
svg . setAttribute ( "width" , box ) ;
svg . setAttribute ( "height" , box ) ;
for ( let g = 1 ; g <= maxGen ; g ++ ) {
const count = 2 * * g , degPer = 360 / count , ri = radii [ g - 1 ] , ro = radii [ g ] ;
for ( let idx = 0 ; idx < count ; idx ++ ) {
const n = nodes [ String ( count + idx ) ] ;
if ( ! n ) continue ;
const a0 = idx * degPer , a1 = a0 + degPer , large = ( a1 - a0 ) > 180 ? 1 : 0 ;
const [ x1 , y1 ] = fanPolar ( ri , a0 ) , [ x2 , y2 ] = fanPolar ( ro , a0 ) ;
const [ x3 , y3 ] = fanPolar ( ro , a1 ) , [ x4 , y4 ] = fanPolar ( ri , a1 ) ;
const path = document . createElementNS ( SVGNS , "path" ) ;
path . setAttribute ( "d" ,
` M ${ fanNum ( x2 ) } ${ fanNum ( y2 ) } A ${ fanNum ( ro ) } ${ fanNum ( ro ) } 0 ${ large } 1 ${ fanNum ( x3 ) } ${ fanNum ( y3 ) } ` +
` L ${ fanNum ( x4 ) } ${ fanNum ( y4 ) } A ${ fanNum ( ri ) } ${ fanNum ( ri ) } 0 ${ large } 0 ${ fanNum ( x1 ) } ${ fanNum ( y1 ) } Z ` ) ;
path . setAttribute ( "class" , "ped-wedge" ) ;
path . style . setProperty ( "--gen" , g ) ;
const key = dogKey ( n ) ;
if ( key && pedRepeat [ key ] > 1 ) {
path . dataset . dogkey = key ;
path . style . setProperty ( "--repeat-hue" , hueFor ( key ) ) ;
path . classList . add ( "ped-wedge-repeat" ) ;
}
const title = document . createElementNS ( SVGNS , "title" ) ;
title . textContent = fanTitle ( n , key ) ;
path . append ( title ) ;
path . addEventListener ( "click" , ( ) => selectFan ( count + idx ) ) ;
svg . append ( path ) ;
if ( degPer >= 20 ) fanLabel ( svg , n , ( a0 + a1 ) / 2 , ri , ro , degPer ) ;
}
}
// centre disc = the dog
const c = document . createElementNS ( SVGNS , "circle" ) ;
c . setAttribute ( "r" , r0 ) ;
c . setAttribute ( "class" , "ped-fan-center" ) ;
c . addEventListener ( "click" , ( ) => selectFan ( 1 ) ) ;
svg . append ( c ) ;
fanCenterLabel ( svg , nodes [ "1" ] , r0 ) ;
pedTree . append ( svg ) ;
if ( pedCaption ) {
pedCaption . hidden = false ;
pedCaption . textContent = "Tap a wedge for its dog. Zoom to read the outer rings." ;
}
}
function fanTitle ( n , key ) {
let t = n . name || "(unnamed)" ;
if ( n . reg ) t += " — " + n . reg ;
if ( key && pedRepeat [ key ] > 1 ) t += " (× " + pedRepeat [ key ] + ")" ;
return t ;
}
function fanLabel ( svg , n , midA , ri , ro , degPer ) {
let rot = midA - 90 ;
if ( rot > 90 && rot < 270 ) rot -= 180 ; // keep upright
const [ px , py ] = fanPolar ( ( ri + ro ) / 2 , midA ) ;
const t = document . createElementNS ( SVGNS , "text" ) ;
t . setAttribute ( "class" , "ped-wedge-label" ) ;
t . setAttribute ( "transform" , ` translate( ${ fanNum ( px ) } ${ fanNum ( py ) } ) rotate( ${ fanNum ( rot ) } ) ` ) ;
const room = Math . floor ( ( ro - ri ) / 6.2 ) ; // chars that fit along the ring
t . textContent = fanTrunc ( n . name || ( n . reg || "?" ) , Math . max ( 6 , room ) ) ;
svg . append ( t ) ;
}
function fanCenterLabel ( svg , n , r0 ) {
if ( ! n ) return ;
const words = ( n . name || "Dog" ) . split ( " " ) ;
const lines = [ ] ;
let line = "" ;
for ( const w of words ) {
if ( ( line + " " + w ) . trim ( ) . length > 12 ) { if ( line ) lines . push ( line ) ; line = w ; }
else line = ( line ? line + " " : "" ) + w ;
}
if ( line ) lines . push ( line ) ;
const shown = lines . slice ( 0 , 3 ) ;
const t = document . createElementNS ( SVGNS , "text" ) ;
t . setAttribute ( "class" , "ped-fan-center-label" ) ;
t . setAttribute ( "text-anchor" , "middle" ) ;
const lh = 12 , y0 = - ( shown . length - 1 ) * lh / 2 ;
shown . forEach ( ( ln , i ) => {
const ts = document . createElementNS ( SVGNS , "tspan" ) ;
ts . setAttribute ( "x" , "0" ) ;
ts . setAttribute ( "y" , fanNum ( y0 + i * lh ) ) ;
ts . textContent = ln ;
t . append ( ts ) ;
} ) ;
svg . append ( t ) ;
}
function fanTrunc ( s , max ) { return s . length > max ? s . slice ( 0 , max - 1 ) + "…" : s ; }
function selectFan ( pos ) {
const n = pedNodes [ String ( pos ) ] ;
if ( ! n || ! pedCaption ) return ;
const key = dogKey ( n ) ;
const bits = [ ] ;
if ( n . reg ) bits . push ( n . reg ) ;
if ( n . titles ) bits . push ( n . titles ) ;
if ( key && pedRepeat [ key ] > 1 ) bits . push ( "appears × " + pedRepeat [ key ] ) ;
pedCaption . hidden = false ;
pedCaption . textContent = ( n . name || "(unnamed)" ) + ( bits . length ? " — " + bits . join ( " · " ) : "" ) ;
togglePedHighlight ( key && pedRepeat [ key ] > 1 ? key : "