mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 08:16:09 +00:00
Le souvenir « Mariage Simone 1943 » ouvrait Big Buck Bunny sur YouTube. Remplacé par 25 s des actualités Polygoon « Bruiloft te Giethoorn » (1947, Beeld en Geluid via Open Beelden, CC BY-SA 3.0 NL), sans le son, en H.264 pour Safari, servi par le site (media-src 'self'). Crédit dans CREDITS.md et sources.json. Les trois autres vidéos YouTube de remplacement de la démo (Témoignage filmé 1960, Anniversaire 2020, Interview archive radio) sont retirées. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
17 KiB
JavaScript
395 lines
17 KiB
JavaScript
// ============================================================
|
||
// Data model: Person / Union / Filiation / Media / Annotation
|
||
// Compatible with the current Galaxy frontend.
|
||
// ============================================================
|
||
|
||
import { t } from '../i18n/index.js'
|
||
import { buildMediaUrl } from '../utils/mediaToken.js'
|
||
|
||
// Démo : noms et lieux restent français, les textes descriptifs sont traduits.
|
||
export let persons = [
|
||
{ id: 1, firstName: 'Jean', lastName: 'Dupont', birthYear: 1890, deathYear: 1965, isAlive: false, photo: '/demo/jean.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Menuisier'), nationality: t('Française'), note: t('Patriarche de la famille') },
|
||
{ id: 2, firstName: 'Marie', lastName: 'Martin', birthYear: 1895, deathYear: 1970, isAlive: false, photo: '/demo/marie.webp', frameType: 'round', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Couturière'), note: t('Atelier de couture rue du Commerce') },
|
||
{ id: 3, firstName: 'Robert', lastName: 'Dupont', birthYear: 1920, deathYear: 1995, isAlive: false, photo: '/demo/robert.webp', frameType: 'rect', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Instituteur'), note: t("A enseigné 35 ans à l'école communale") },
|
||
{ id: 4, firstName: 'André', lastName: 'Dupont', birthYear: 1923, deathYear: 2000, isAlive: false, photo: '/demo/andre.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Charpentier') },
|
||
{ id: 5, firstName: 'Simone', lastName: 'Petit', birthYear: 1925, deathYear: 2010, isAlive: false, photo: '/demo/simone.webp', frameType: 'round', birthPlace: 'Guéret', region: 'Creuse', profession: t('Infirmière') },
|
||
{ id: 9, firstName: 'Isabelle', lastName: 'Bernard', birthYear: 1928, isAlive: true, photo: '/demo/isabelle.webp', frameType: 'rect', birthPlace: 'Fort-de-France', region: 'Martinique', profession: t('Sage-femme'), note: t('Doyenne de la famille, vit à Brive') },
|
||
{ id: 6, firstName: 'Jacques', lastName: 'Dupont', birthYear: 1945, deathYear: 2015, isAlive: false, photo: '/demo/jacques.webp', frameType: 'polaroid', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Journaliste'), note: t('Reporter au Populaire du Centre') },
|
||
{ id: 7, firstName: 'Philippe', lastName: 'Dupont', birthYear: 1948, isAlive: true, photo: '/demo/philippe.webp', frameType: 'round', birthPlace: 'Limoges', region: 'Haute-Vienne', profession: t('Pharmacien') },
|
||
{ id: 8, firstName: 'Sophie', lastName: 'Dupont', birthYear: 1960, isAlive: true, photo: '/demo/sophie.webp', frameType: 'rect', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Architecte'), note: t('Cabinet d’architecture à Bordeaux') },
|
||
{ id: 10, firstName: 'Thomas', lastName: 'Dupont', birthYear: 1962, isAlive: true, photo: '/demo/thomas.webp', frameType: 'polaroid', birthPlace: 'Brive-la-Gaillarde', region: 'Corrèze', profession: t('Vétérinaire') },
|
||
{ id: 11, firstName: 'Claire', lastName: 'Dupont', birthYear: 1955, isAlive: true, photo: '/demo/claire.webp', birthPlace: 'Paris', region: 'Ile-de-France', profession: t('Enseignante') },
|
||
]
|
||
|
||
export let medias = [
|
||
{ id: 'm1', personId: 1, type: 'photo', url: '/demo/souvenir-mariage-1918-thumb.webp', urlHd: '/demo/souvenir-mariage-1918.webp', label: t('Mariage 1918') },
|
||
{ id: 'm2', personId: 1, type: 'photo', url: '/demo/souvenir-atelier-thumb.webp', urlHd: '/demo/souvenir-atelier.webp', label: t('Atelier de menuiserie') },
|
||
{ id: 'm4', personId: 2, type: 'photo', url: '/demo/souvenir-jeunesse-thumb.webp', urlHd: '/demo/souvenir-jeunesse.webp', label: t('Jeunesse') },
|
||
{ id: 'm5', personId: 2, type: 'photo', url: '/demo/souvenir-portrait-famille-thumb.webp', urlHd: '/demo/souvenir-portrait-famille.webp', label: t('Portrait de famille') },
|
||
{ id: 'm6', personId: 3, type: 'photo', url: '/demo/souvenir-service-militaire-thumb.webp', urlHd: '/demo/souvenir-service-militaire.webp', label: t('Service militaire') },
|
||
// Actualités Polygoon 1947, CC BY-SA 3.0 NL, voir public/demo/CREDITS.md
|
||
{ id: 'm7', personId: 3, type: 'video', url: '/demo/souvenir-mariage-1947.mp4', mimeType: 'video/mp4', label: t('Mariage Simone 1943') },
|
||
{ id: 'm8', personId: 3, type: 'photo', url: '/demo/souvenir-annees-70-thumb.webp', urlHd: '/demo/souvenir-annees-70.webp', label: t('Années 70') },
|
||
{ id: 'm9', personId: 8, type: 'photo', url: '/demo/souvenir-diplome-thumb.webp', urlHd: '/demo/souvenir-diplome.webp', label: t('Diplôme') },
|
||
{ id: 'm10', personId: 8, type: 'photo', url: '/demo/souvenir-plan-cabinet-thumb.webp', urlHd: '/demo/souvenir-plan-cabinet.webp', label: t('Plan du cabinet 1985') },
|
||
{ id: 'm12', personId: 6, type: 'photo', url: '/demo/souvenir-redaction-thumb.webp', urlHd: '/demo/souvenir-redaction.webp', label: t('En rédaction') },
|
||
]
|
||
|
||
export let unions = [
|
||
{ id: 'u1', partner1Id: 1, partner2Id: 2, unionType: 'mariage', startYear: 1918, endYear: null, displayOrder: 1 },
|
||
{ id: 'u2', partner1Id: 3, partner2Id: 5, unionType: 'mariage', startYear: 1943, endYear: 1958, displayOrder: 1 },
|
||
{ id: 'u3', partner1Id: 3, partner2Id: 9, unionType: 'mariage', startYear: 1959, endYear: null, displayOrder: 2 },
|
||
]
|
||
|
||
export let filiations = [
|
||
{ id: 'f1', childId: 3, unionId: 'u1', parentId: null, parentageType: 'biologique', displayOrder: 1 },
|
||
{ id: 'f2', childId: 4, unionId: 'u1', parentId: null, parentageType: 'biologique', displayOrder: 2 },
|
||
{ id: 'f3', childId: 6, unionId: 'u2', parentId: null, parentageType: 'biologique', displayOrder: 1 },
|
||
{ id: 'f4', childId: 7, unionId: 'u2', parentId: null, parentageType: 'biologique', displayOrder: 2 },
|
||
{ id: 'f5', childId: 8, unionId: 'u3', parentId: null, parentageType: 'biologique', displayOrder: 1 },
|
||
{ id: 'f6', childId: 10, unionId: 'u3', parentId: null, parentageType: 'biologique', displayOrder: 2 },
|
||
{ id: 'f7', childId: 11, unionId: null, parentId: 4, parentageType: 'inconnu', displayOrder: 1 },
|
||
]
|
||
|
||
export let annotations = []
|
||
|
||
const INITIAL_PERSONS = JSON.parse(JSON.stringify(persons))
|
||
const INITIAL_MEDIAS = JSON.parse(JSON.stringify(medias))
|
||
const INITIAL_UNIONS = JSON.parse(JSON.stringify(unions))
|
||
const INITIAL_FILIATIONS = JSON.parse(JSON.stringify(filiations))
|
||
|
||
function parseYearFromDate(value) {
|
||
if (!value || typeof value !== 'string') {
|
||
return null
|
||
}
|
||
|
||
const year = Number.parseInt(value.slice(0, 4), 10)
|
||
return Number.isFinite(year) ? year : null
|
||
}
|
||
|
||
function pickFrameType(id) {
|
||
const frameTypes = ['round', 'rect', 'polaroid']
|
||
const text = String(id)
|
||
let hash = 0
|
||
|
||
for (let i = 0; i < text.length; i += 1) {
|
||
hash += text.charCodeAt(i)
|
||
}
|
||
|
||
return frameTypes[hash % frameTypes.length]
|
||
}
|
||
|
||
// Adresse d'un fichier de l'arbre avec le jeton médias (voir utils/mediaToken.js)
|
||
function buildProtectedAssetUrl(pathOrUrl, apiBaseUrl, mediaToken, cacheBust = null) {
|
||
let protectedUrl = buildMediaUrl(pathOrUrl, apiBaseUrl, mediaToken)
|
||
if (!protectedUrl) {
|
||
return null
|
||
}
|
||
|
||
if (cacheBust !== null && cacheBust !== undefined) {
|
||
const separator = protectedUrl.includes('?') ? '&' : '?'
|
||
protectedUrl = `${protectedUrl}${separator}v=${encodeURIComponent(String(cacheBust))}`
|
||
}
|
||
|
||
return protectedUrl
|
||
}
|
||
|
||
function mapRemotePersonToLocal(person, current = null) {
|
||
const birthYear = parseYearFromDate(person?.birthDate)
|
||
const deathYear = parseYearFromDate(person?.deathDate)
|
||
|
||
return {
|
||
id: person?.id ?? current?.id,
|
||
firstName: person?.firstName || current?.firstName || t('Inconnu'),
|
||
lastName: person?.lastName || current?.lastName || '',
|
||
birthName: person?.birthName ?? current?.birthName ?? null,
|
||
birthYear,
|
||
birthDate: person?.birthDate || current?.birthDate || null,
|
||
deathDate: person?.deathDate || current?.deathDate || null,
|
||
deathYear,
|
||
isAlive: !deathYear,
|
||
frameType: current?.frameType || pickFrameType(person?.id),
|
||
note: person?.notes || null,
|
||
birthPlace: person?.birthPlace || null,
|
||
region: person?.region || null,
|
||
profession: person?.profession || null,
|
||
nationality: person?.nationality || null,
|
||
photo: current?.photo || null,
|
||
}
|
||
}
|
||
|
||
export function resetToDemoData() {
|
||
persons = JSON.parse(JSON.stringify(INITIAL_PERSONS))
|
||
medias = JSON.parse(JSON.stringify(INITIAL_MEDIAS))
|
||
unions = JSON.parse(JSON.stringify(INITIAL_UNIONS))
|
||
filiations = JSON.parse(JSON.stringify(INITIAL_FILIATIONS))
|
||
}
|
||
|
||
export function loadGraphData(graph, options = {}) {
|
||
const mediaToken = options.mediaToken || ''
|
||
const apiBaseUrl = (options.apiBaseUrl || '').replace(/\/$/, '')
|
||
const avatarCacheBust = options.avatarCacheBust ?? null
|
||
|
||
const remotePersons = Array.isArray(graph?.persons) ? graph.persons : []
|
||
const remoteUnions = Array.isArray(graph?.unions) ? graph.unions : []
|
||
const remoteFiliations = Array.isArray(graph?.filiations) ? graph.filiations : []
|
||
const remoteMedias = Array.isArray(graph?.medias) ? graph.medias : []
|
||
const remoteAnnotations = Array.isArray(graph?.annotations) ? graph.annotations : []
|
||
|
||
persons = remotePersons.map((person) => {
|
||
const localPerson = mapRemotePersonToLocal(person)
|
||
localPerson.photo = buildProtectedAssetUrl(person.avatarUrlPath || null, apiBaseUrl, mediaToken, avatarCacheBust)
|
||
return localPerson
|
||
})
|
||
|
||
const personIdSet = new Set(persons.map((person) => person.id))
|
||
|
||
unions = remoteUnions
|
||
.map((union, index) => ({
|
||
id: union.id,
|
||
partner1Id: union.partner1PersonId,
|
||
partner2Id: union.partner2PersonId,
|
||
unionType: union.unionType || null,
|
||
startYear: parseYearFromDate(union.startDate),
|
||
endYear: parseYearFromDate(union.endDate),
|
||
displayOrder: Number.isFinite(union.displayOrder) ? union.displayOrder : index + 1,
|
||
}))
|
||
.filter((union) => (
|
||
personIdSet.has(union.partner1Id) &&
|
||
personIdSet.has(union.partner2Id)
|
||
))
|
||
|
||
const unionIdSet = new Set(unions.map((union) => union.id))
|
||
|
||
const seenUnionChildKey = new Set()
|
||
filiations = remoteFiliations
|
||
.map((filiation, index) => ({
|
||
id: filiation.id,
|
||
childId: filiation.childPersonId,
|
||
unionId: filiation.viaUnionId || null,
|
||
parentId: filiation.viaUnionId ? null : filiation.parentPersonId,
|
||
parentageType: filiation.parentageType || 'biologique',
|
||
displayOrder: Number.isFinite(filiation.displayOrder) ? filiation.displayOrder : index + 1,
|
||
}))
|
||
.filter((filiation) => {
|
||
if (!personIdSet.has(filiation.childId)) {
|
||
return false
|
||
}
|
||
|
||
if (!filiation.unionId) {
|
||
return filiation.parentId ? personIdSet.has(filiation.parentId) : false
|
||
}
|
||
|
||
if (!unionIdSet.has(filiation.unionId)) {
|
||
return false
|
||
}
|
||
|
||
const key = `${filiation.unionId}:${filiation.childId}`
|
||
if (seenUnionChildKey.has(key)) {
|
||
return false
|
||
}
|
||
|
||
seenUnionChildKey.add(key)
|
||
return true
|
||
})
|
||
|
||
medias = remoteMedias
|
||
.filter((media) => personIdSet.has(media.personId))
|
||
.map((media, index) => {
|
||
const mediaUrl = buildProtectedAssetUrl(media.urlPath || '', apiBaseUrl, mediaToken)
|
||
|
||
return {
|
||
id: media.id,
|
||
personId: media.personId,
|
||
type: media.type || 'photo',
|
||
url: mediaUrl,
|
||
urlHd: mediaUrl,
|
||
label: media.caption || t('Média {n}', { n: index + 1 }),
|
||
source: media.source || null,
|
||
displayOrder: Number.isFinite(media.displayOrder) ? media.displayOrder : index + 1,
|
||
isFeatured: Boolean(media.isFeatured),
|
||
mimeType: media.mimeType || null,
|
||
}
|
||
})
|
||
|
||
annotations = remoteAnnotations.map((ann) => {
|
||
let content = ann.content || ''
|
||
// Annotation photo : l'adresse est reconstruite ici depuis photoPath, le serveur n'en garde aucune
|
||
if (ann.type === 'photo' && content) {
|
||
try {
|
||
const parsed = typeof content === 'string' ? JSON.parse(content) : content
|
||
if (parsed.photoPath) {
|
||
parsed.url = buildProtectedAssetUrl(parsed.photoPath, apiBaseUrl, mediaToken)
|
||
content = JSON.stringify(parsed)
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
return {
|
||
id: ann.id,
|
||
type: ann.type || 'text',
|
||
x: ann.x ?? 0,
|
||
y: ann.y ?? 0,
|
||
content,
|
||
style: ann.style || {},
|
||
zIndex: ann.zIndex ?? 0,
|
||
createdBy: ann.createdBy || null,
|
||
}
|
||
})
|
||
}
|
||
|
||
// Index par personne, reconstruits quand le tableau est réassigné (les tableaux ne sont jamais
|
||
// mutés en place). Appelés pour chaque nœud à chaque frame par la galaxie : un parcours linéaire
|
||
// coûterait nœuds × éléments comparaisons par frame sur un grand arbre.
|
||
const EMPTY_MEDIAS = []
|
||
let mediasIndexSource = null
|
||
let mediasByPerson = new Map()
|
||
|
||
export function getPersonMedias(personId) {
|
||
if (mediasIndexSource !== medias) {
|
||
mediasIndexSource = medias
|
||
mediasByPerson = new Map()
|
||
for (const media of medias) {
|
||
if (!mediasByPerson.has(media.personId)) mediasByPerson.set(media.personId, [])
|
||
mediasByPerson.get(media.personId).push(media)
|
||
}
|
||
for (const list of mediasByPerson.values()) {
|
||
list.sort((a, b) => {
|
||
const aOrder = Number.isFinite(a.displayOrder) ? a.displayOrder : Number.MAX_SAFE_INTEGER
|
||
const bOrder = Number.isFinite(b.displayOrder) ? b.displayOrder : Number.MAX_SAFE_INTEGER
|
||
return aOrder - bOrder
|
||
})
|
||
}
|
||
}
|
||
return mediasByPerson.get(personId) || EMPTY_MEDIAS
|
||
}
|
||
|
||
let personsIndexSource = null
|
||
let personsById = new Map()
|
||
|
||
export function getPersonById(personId) {
|
||
if (personsIndexSource !== persons) {
|
||
personsIndexSource = persons
|
||
personsById = new Map(persons.map((person) => [String(person.id), person]))
|
||
}
|
||
return personsById.get(String(personId)) || null
|
||
}
|
||
|
||
export function upsertPersonFromGraphPayload(person) {
|
||
if (!person?.id) {
|
||
return null
|
||
}
|
||
|
||
const index = persons.findIndex((candidate) => String(candidate.id) === String(person.id))
|
||
const current = index >= 0 ? persons[index] : null
|
||
const localPerson = mapRemotePersonToLocal(person, current)
|
||
|
||
if (index >= 0) {
|
||
persons = [
|
||
...persons.slice(0, index),
|
||
localPerson,
|
||
...persons.slice(index + 1),
|
||
]
|
||
} else {
|
||
persons = [...persons, localPerson]
|
||
}
|
||
|
||
return localPerson
|
||
}
|
||
|
||
export function getParents(personId) {
|
||
const fil = filiations.filter((filiation) => filiation.childId === personId)
|
||
const result = []
|
||
const seenParentIds = new Set()
|
||
|
||
for (const filiation of fil) {
|
||
if (filiation.unionId) {
|
||
const union = unions.find((currentUnion) => currentUnion.id === filiation.unionId)
|
||
if (union) {
|
||
const p1 = persons.find((person) => person.id === union.partner1Id)
|
||
const p2 = persons.find((person) => person.id === union.partner2Id)
|
||
if (p1 && !seenParentIds.has(p1.id)) {
|
||
seenParentIds.add(p1.id)
|
||
result.push(p1)
|
||
}
|
||
|
||
if (p2 && !seenParentIds.has(p2.id)) {
|
||
seenParentIds.add(p2.id)
|
||
result.push(p2)
|
||
}
|
||
}
|
||
} else if (filiation.parentId) {
|
||
const parent = persons.find((person) => person.id === filiation.parentId)
|
||
if (parent && !seenParentIds.has(parent.id)) {
|
||
seenParentIds.add(parent.id)
|
||
result.push(parent)
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
export function getChildren(personId) {
|
||
const childIds = new Set()
|
||
const personUnions = unions.filter((union) => union.partner1Id === personId || union.partner2Id === personId)
|
||
|
||
for (const union of personUnions) {
|
||
const childrenInUnion = filiations.filter((filiation) => filiation.unionId === union.id)
|
||
childrenInUnion.forEach((filiation) => childIds.add(filiation.childId))
|
||
}
|
||
|
||
const directFiliations = filiations.filter((filiation) => filiation.parentId === personId)
|
||
directFiliations.forEach((filiation) => childIds.add(filiation.childId))
|
||
|
||
return [...childIds]
|
||
.map((id) => persons.find((person) => person.id === id))
|
||
.filter(Boolean)
|
||
.sort((a, b) => {
|
||
const aYear = Number.isFinite(a.birthYear) ? a.birthYear : Number.MAX_SAFE_INTEGER
|
||
const bYear = Number.isFinite(b.birthYear) ? b.birthYear : Number.MAX_SAFE_INTEGER
|
||
return aYear - bYear
|
||
})
|
||
}
|
||
|
||
export function getPersonUnions(personId) {
|
||
return unions
|
||
.filter((union) => union.partner1Id === personId || union.partner2Id === personId)
|
||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||
}
|
||
|
||
export function getUnionChildren(unionId) {
|
||
return filiations
|
||
.filter((filiation) => filiation.unionId === unionId)
|
||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||
.map((filiation) => persons.find((person) => person.id === filiation.childId))
|
||
.filter(Boolean)
|
||
}
|
||
|
||
export function getChildFiliation(childId) {
|
||
return filiations.find((filiation) => filiation.childId === childId) || null
|
||
}
|
||
|
||
// ---- Annotations helpers ----
|
||
|
||
let annotationIdCounter = 0
|
||
|
||
export function addAnnotation(ann) {
|
||
annotationIdCounter += 1
|
||
const id = ann.id || `local-ann-${annotationIdCounter}`
|
||
const newAnn = { ...ann, id }
|
||
annotations = [...annotations, newAnn]
|
||
return newAnn
|
||
}
|
||
|
||
export function updateAnnotation(id, changes) {
|
||
const index = annotations.findIndex((a) => a.id === id)
|
||
if (index < 0) return null
|
||
const updated = { ...annotations[index], ...changes }
|
||
annotations = [...annotations.slice(0, index), updated, ...annotations.slice(index + 1)]
|
||
return updated
|
||
}
|
||
|
||
export function removeAnnotation(id) {
|
||
annotations = annotations.filter((a) => a.id !== id)
|
||
}
|