@@ -21,6 +21,7 @@
"poo" : "Poo" ,
"poo" : "Poo" ,
"weight" : "Weigh-in" ,
"weight" : "Weigh-in" ,
"training" : "Training" ,
"training" : "Training" ,
"note" : "Note" ,
} ;
} ;
// ---------- photos: IndexedDB store ----------
// ---------- photos: IndexedDB store ----------
@@ -291,6 +292,24 @@
return ` ${ wk } ( ${ monthPhrase } ) old ` ;
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:
// 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
// 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.
// 12 months. No birthday (or an adult dog) → no goal.
@@ -876,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 ----------
// ---------- lightbox ----------
const lightbox = document . getElementById ( "lightbox" ) ;
const lightbox = document . getElementById ( "lightbox" ) ;
const lightboxImg = document . getElementById ( "lightbox-img" ) ;
const lightboxImg = document . getElementById ( "lightbox-img" ) ;
@@ -909,6 +975,27 @@
render ( ) ;
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.
// Sync every "(last N days)" header and the picker's active button.
function renderChartWindow ( ) {
function renderChartWindow ( ) {
const n = chartDays ( ) ;
const n = chartDays ( ) ;
@@ -920,6 +1007,10 @@
document . querySelectorAll ( ".chart-days-picker button" ) . forEach ( b => {
document . querySelectorAll ( ".chart-days-picker button" ) . forEach ( b => {
b . classList . toggle ( "active" , Number ( b . dataset . days ) === n ) ;
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 ----------
// ---------- daily charts ----------
@@ -1076,13 +1167,19 @@
const innerW = W - ML - MR ;
const innerW = W - ML - MR ;
const innerH = H - MT - MB ;
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 { yMax , steps : ySteps } = niceAxis ( rawMax ) ;
const groupGap = days . length > 14 ? 2 : 4 ;
const groupGap = days . length > 14 ? 2 : 4 ;
const innerBarGap = days . length > 14 ? 0.5 : 1.5 ;
const innerBarGap = days . length > 14 ? 0.5 : 1.5 ;
const groupW = ( innerW - ( days . length - 1 ) * groupGap ) / days . length ;
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 = [ ] ;
const parts = [ ] ;
for ( let i = 0 ; i <= ySteps ; i ++ ) {
for ( let i = 0 ; i <= ySteps ; i ++ ) {
@@ -1092,12 +1189,6 @@
parts . push ( ` <text x=" ${ ML - 4 } " y=" ${ y + 3 } " text-anchor="end"> ${ v } </text> ` ) ;
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 ( ) ) ;
const selYmd = ymd ( selectedDay ( ) ) ;
days . forEach ( ( d , i ) => {
days . forEach ( ( d , i ) => {
const isToday = i === days . length - 1 ;
const isToday = i === days . length - 1 ;
@@ -1669,7 +1760,7 @@
li . className = "ww weight-ww" ;
li . className = "ww weight-ww" ;
const date = document . createElement ( "span" ) ;
const date = document . createElement ( "span" ) ;
date . className = "ww-range" ;
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" } ) ;
const dateStr = new Date ( w . at ) . toLocaleDateString ( undefined , { month : "short" , day : "numeric" } ) ;
date . textContent = age ? ` ${ dateStr } · ${ age } ` : dateStr ;
date . textContent = age ? ` ${ dateStr } · ${ age } ` : dateStr ;
const val = document . createElement ( "span" ) ;
const val = document . createElement ( "span" ) ;
@@ -1888,7 +1979,7 @@
const ageEl = document . getElementById ( "puppy-age" ) ;
const ageEl = document . getElementById ( "puppy-age" ) ;
title . textContent = cfg . name ? ` 🐶 ${ cfg . name } ` : "🐶 Puppy Tracker" ;
title . textContent = cfg . name ? ` 🐶 ${ cfg . name } ` : "🐶 Puppy Tracker" ;
document . title = cfg . name ? ` ${ cfg . name } · Puppy Tracker ` : "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 . textContent = ageText ;
ageEl . hidden = ! ageText ;
ageEl . hidden = ! ageText ;
// Use the puppy's name in the sleep-timeline heading rather than assuming a
// Use the puppy's name in the sleep-timeline heading rather than assuming a
@@ -1930,6 +2021,7 @@
renderHourHeatmap ( events ) ;
renderHourHeatmap ( events ) ;
renderTraining ( events ) ;
renderTraining ( events ) ;
renderWeight ( events ) ;
renderWeight ( events ) ;
renderNotes ( events ) ;
renderHistory ( events ) ;
renderHistory ( events ) ;
}
}
@@ -2161,11 +2253,21 @@
function openNoteDialog ( type ) {
function openNoteDialog ( type ) {
pendingType = type ;
pendingType = type ;
noteInput . value = "" ;
noteInput . value = "" ;
noteTimeEdited = false ;
const isNote = type === "note" ;
const now = Date . now ( ) ;
// A note is about a day, so default it to the day you're viewing (at the
noteDate . value = toDateInput ( now ) ;
// current clock time). The logging types default to "now"; leaving
noteTime . value = toTimeInput ( now ) ;
// noteTimeEdited false lets noteDialogAt() stamp the exact instant.
noteTitle . textContent = ` Log ${ EVENT _LABELS [ type ] } ` ;
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 isWeight = type === "weight" ;
const isEat = type === "eat" ;
const isEat = type === "eat" ;
noteWeightField . hidden = ! isWeight ;
noteWeightField . hidden = ! isWeight ;
@@ -2215,6 +2317,10 @@
document . getElementById ( "note-save" ) . addEventListener ( "click" , async ( e ) => {
document . getElementById ( "note-save" ) . addEventListener ( "click" , async ( e ) => {
e . preventDefault ( ) ;
e . preventDefault ( ) ;
if ( ! pendingType ) { noteDialog . close ( ) ; return ; }
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 ;
let weight ;
if ( pendingType === "weight" ) {
if ( pendingType === "weight" ) {
weight = parseFloat ( noteWeight . value ) ;
weight = parseFloat ( noteWeight . value ) ;
@@ -2404,6 +2510,7 @@
const settingsBirthday = document . getElementById ( "settings-birthday" ) ;
const settingsBirthday = document . getElementById ( "settings-birthday" ) ;
const settingsPedigree = document . getElementById ( "settings-pedigree" ) ;
const settingsPedigree = document . getElementById ( "settings-pedigree" ) ;
const settingsTheme = document . getElementById ( "settings-theme" ) ;
const settingsTheme = document . getElementById ( "settings-theme" ) ;
const settingsConfetti = document . getElementById ( "settings-confetti" ) ;
// Apply live so the toggle previews immediately (independent of Save/Cancel).
// Apply live so the toggle previews immediately (independent of Save/Cancel).
settingsTheme . addEventListener ( "change" , ( ) => {
settingsTheme . addEventListener ( "change" , ( ) => {
@@ -2416,6 +2523,7 @@
settingsBirthday . value = cfg . birthday ;
settingsBirthday . value = cfg . birthday ;
settingsPedigree . value = cfg . pedigreeId ;
settingsPedigree . value = cfg . pedigreeId ;
settingsTheme . checked = effectiveTheme ( ) === "dark" ;
settingsTheme . checked = effectiveTheme ( ) === "dark" ;
settingsConfetti . checked = confettiEnabled ( ) ;
settingsDialog . showModal ( ) ;
settingsDialog . showModal ( ) ;
setTimeout ( ( ) => settingsName . focus ( ) , 50 ) ;
setTimeout ( ( ) => settingsName . focus ( ) , 50 ) ;
}
}
@@ -2431,6 +2539,7 @@
updatedAt : Date . now ( ) ,
updatedAt : Date . now ( ) ,
} ;
} ;
saveConfig ( cfg ) ; // cache locally for instant + offline paint
saveConfig ( cfg ) ; // cache locally for instant + offline paint
setConfettiEnabled ( settingsConfetti . checked ) ; // device-local, not synced
renderHeader ( ) ;
renderHeader ( ) ;
refreshPedigreeButton ( ) ;
refreshPedigreeButton ( ) ;
settingsDialog . close ( ) ;
settingsDialog . close ( ) ;
@@ -2499,6 +2608,27 @@
pedFoldAll . textContent = collapsed ? "Expand all" : "Collapse all" ;
pedFoldAll . textContent = collapsed ? "Expand all" : "Collapse all" ;
}
}
pedFoldAll . addEventListener ( "click" , ( ) => pedSetAll ( pedFoldAll . textContent [ 0 ] === "C" ) ) ;
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.
// Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page.
pedTree . addEventListener ( "wheel" , ( e ) => {
pedTree . addEventListener ( "wheel" , ( e ) => {
if ( ! e . ctrlKey && ! e . metaKey ) return ;
if ( ! e . ctrlKey && ! e . metaKey ) return ;
@@ -2573,7 +2703,7 @@
const cached = loadPedCache ( q ) ;
const cached = loadPedCache ( q ) ;
if ( cached && cached . nodes ) {
if ( cached && cached . nodes ) {
renderSubject ( cached . subject ) ;
renderSubject ( cached . subject ) ;
renderTree ( cached . nodes ) ;
renderPedigree ( cached . nodes ) ;
}
}
if ( ! navigator . onLine ) {
if ( ! navigator . onLine ) {
setPedStatus ( cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection." ,
setPedStatus ( cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection." ,
@@ -2606,7 +2736,7 @@
const data = await res . json ( ) ;
const data = await res . json ( ) ;
if ( data . status === "choose" ) { renderChoose ( data . matches || [ ] ) ; return ; }
if ( data . status === "choose" ) { renderChoose ( data . matches || [ ] ) ; return ; }
renderSubject ( data . subject ) ;
renderSubject ( data . subject ) ;
renderTree ( data . nodes || { } ) ;
renderPedigree ( data . nodes || { } ) ;
if ( data . status === "done" ) {
if ( data . status === "done" ) {
savePedCache ( q , data . subject , data . nodes || { } ) ;
savePedCache ( q , data . subject , data . nodes || { } ) ;
setPedDone ( ) ;
setPedDone ( ) ;
@@ -2625,7 +2755,7 @@
if ( res . status === 401 ) { handleLoggedOut ( ) ; return ; }
if ( res . status === 401 ) { handleLoggedOut ( ) ; return ; }
if ( ! res . ok ) { setPedStatus ( "Lost track of the pedigree crawl." , "error" ) ; return ; }
if ( ! res . ok ) { setPedStatus ( "Lost track of the pedigree crawl." , "error" ) ; return ; }
const data = await res . json ( ) ;
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 === "done" ) { savePedCache ( q , subject , data . nodes || { } ) ; setPedDone ( ) ; return ; }
if ( data . status === "error" ) { setPedStatus ( data . error || "Pedigree crawl failed." , "error" ) ; return ; }
if ( data . status === "error" ) { setPedStatus ( data . error || "Pedigree crawl failed." , "error" ) ; return ; }
setPedStatus ( ` Tracing ancestry… ${ pedCountText ( ) } ` , "busy" ) ;
setPedStatus ( ` Tracing ancestry… ${ pedCountText ( ) } ` , "busy" ) ;
@@ -2725,17 +2855,22 @@
}
}
let pedRepeat = { } ; // dogKey -> occurrence count, for the current tree
let pedRepeat = { } ; // dogKey -> occurrence count, for the current tree
function renderTree ( nodes ) {
// 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 ;
pedNodes = nodes ;
pedTree . textContent = "" ;
// Count how many positions each dog fills, to flag pedigree collapse.
pedRepeat = { } ;
pedRepeat = { } ;
for ( const k in nodes ) {
for ( const k in nodes ) {
const id = dogKey ( nodes [ k ] ) ;
const id = dogKey ( nodes [ k ] ) ;
if ( id ) pedRepeat [ id ] = ( pedRepeat [ id ] || 0 ) + 1 ;
if ( id ) pedRepeat [ id ] = ( pedRepeat [ id ] || 0 ) + 1 ;
}
}
const anyRepeat = Object . values ( pedRepeat ) . some ( ( c ) => c > 1 ) ;
if ( pedRepeatNote ) pedRepeatNote . hidden = ! Object . values ( pedRepeat ) . some ( ( c ) => c > 1 ) ;
if ( pedRepeatNote ) pedRepeatNote . hidden = ! anyRepeat ;
pedTree . textContent = "" ;
if ( pedView === "fan" ) renderFan ( nodes ) ; else renderTree ( nodes ) ;
updatePedViewControls ( ) ;
}
function renderTree ( nodes ) {
const root = buildPedNode ( nodes , 1 , 0 ) ;
const root = buildPedNode ( nodes , 1 , 0 ) ;
if ( ! root ) return ;
if ( ! root ) return ;
const ul = document . createElement ( "ul" ) ;
const ul = document . createElement ( "ul" ) ;
@@ -2745,15 +2880,152 @@
if ( pedFoldAll ) pedFoldAll . textContent = "Collapse all" ; // fresh tree starts partly open
if ( pedFoldAll ) pedFoldAll . textContent = "Collapse all" ; // fresh tree starts partly open
}
}
// Highlight (or unhighlight) every card that is the same dog as `key`.
// ---- 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 : "