From e2a1c97c00de523ea2c931362b61fd876a2a0bb9 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Fri, 4 Sep 2026 16:48:42 +0200 Subject: [PATCH] Added demo villa (test 3d scene) + misc --- next.config.ts | 7 + src/app/[slug]/[configId]/layout.tsx | 7 +- .../[configId]/sections/[sectionId]/page.tsx | 13 +- src/app/demo/villa-echternach/page.tsx | 482 ++++++++++++++++++ src/app/demo/villa-echternach/scene.ts | 460 +++++++++++++++++ src/app/globals.css | 65 +++ src/components/QRScannerButton.tsx | 7 +- src/components/sections/ArticleSection.tsx | 431 +++++++++++----- src/components/sections/MapSection.tsx | 311 ++++++----- src/components/sections/ParcoursSection.tsx | 104 +++- src/components/sections/QuizSection.tsx | 181 ++++--- src/components/sections/map/LeafletMap.tsx | 41 +- src/components/ui/FloatingPanel.tsx | 133 +++++ src/components/ui/PointFilter.tsx | 237 +++++++++ src/components/ui/floating-panel.css | 144 ++++++ src/components/ui/point-filter.css | 176 +++++++ src/hooks/useOrientation.ts | 22 + 17 files changed, 2466 insertions(+), 355 deletions(-) create mode 100644 src/app/demo/villa-echternach/page.tsx create mode 100644 src/app/demo/villa-echternach/scene.ts create mode 100644 src/components/ui/FloatingPanel.tsx create mode 100644 src/components/ui/PointFilter.tsx create mode 100644 src/components/ui/floating-panel.css create mode 100644 src/components/ui/point-filter.css create mode 100644 src/hooks/useOrientation.ts diff --git a/next.config.ts b/next.config.ts index 1659975..b351eaa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,6 +17,13 @@ const nextConfig: NextConfig = { hostname: '**', }, ], + // Next 16 refuse toute image dont l'hôte résout vers une IP privée + // (protection anti-SSRF) et renvoie « "url" parameter is not allowed », + // le même message qu'un motif non autorisé. Nécessaire pour les visuels + // de démonstration servis sur localhost:8099. + // ⚠️ À ne pas emporter en production : cela rouvre la porte que cette + // protection ferme. + dangerouslyAllowLocalIP: true, }, }; diff --git a/src/app/[slug]/[configId]/layout.tsx b/src/app/[slug]/[configId]/layout.tsx index 8ac4d51..a763f67 100644 --- a/src/app/[slug]/[configId]/layout.tsx +++ b/src/app/[slug]/[configId]/layout.tsx @@ -23,15 +23,16 @@ export default async function ConfigLayout({ const theme = resolveColors(instance, config) const loaderImageUrl = config.loaderImageUrl ?? instance.loaderImageUrl + const showAssistant = !!(instance.isAssistant && instance.publicApiKey) return ( -
+
{loaderImageUrl && } {children} - {instance.isAssistant && instance.publicApiKey && ( + {showAssistant && ( s.id === sectionId) ?? sections.flatMap((s) => s.menu?.sections ?? []).find((s) => s.id === sectionId) -if (!section || section.isActive === false) notFound() + if (!section || section.isActive === false) notFound() // Parcours sections expose guided paths via their own endpoint if (section.type === 'Parcours' && section.parcours) { @@ -77,7 +77,16 @@ if (!section || section.isActive === false) notFound() case 'Quiz': content = ; break case 'Game': content = ; break case 'Event': content = ; break - case 'Parcours': content = ; break + case 'Parcours': + // La bulle assistant vit dans le layout : on la masque le temps du parcours, + // l’écran étant déjà pris par la navigation guidée. + content = ( + <> + + + + ) + break case 'Web': content = ; break default: content = ( diff --git a/src/app/demo/villa-echternach/page.tsx b/src/app/demo/villa-echternach/page.tsx new file mode 100644 index 0000000..f65cd91 --- /dev/null +++ b/src/app/demo/villa-echternach/page.tsx @@ -0,0 +1,482 @@ +'use client' + +/* Page de démonstration commerciale — non branchée sur l'API. + Route : /demo/villa-echternach + Sert à montrer au client à quoi ressemblerait une section « scène 3D » avec + comparaison avant / aujourd'hui. Le chrome (AppBar, panneau flottant, jetons + de couleur) est celui de l'app ; seule la scène est codée en dur. */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { CSSProperties } from 'react' +import AppBar from '@/components/ui/AppBar' +import FloatingPanel from '@/components/ui/FloatingPanel' +import { + buildVilla, + drawFaces, + drawSky, + project, + HOTSPOTS, + type Camera, + type Face, +} from './scene' + +type Mode = 'ruins' | 'roman' | 'compare' + +const THEME = { + '--color-primary': '#6b4a2e', + '--color-secondary': '#d8c7b0', + '--color-primary-light': 'rgba(107, 74, 46, 0.12)', + '--color-on-primary': '#ffffff', + '--color-surface': '#faf7f2', + '--color-border': '#e6ddd0', + '--color-text': '#2a211a', +} as CSSProperties + +/* Cadrage par defaut : le site tient entre le panneau de gauche et la fiche de + droite sur un ecran 16/9, sans rien laisser sortir du cadre. */ +const VIEW0: Camera = { yaw: -0.72, pitch: 0.66, dist: 315, tx: -6, ty: 6, tz: -8 } + +const MODES: { id: Mode; label: string; sub: string }[] = [ + { id: 'ruins', label: "Aujourd'hui", sub: 'Vestiges dégagés' }, + { id: 'roman', label: 'Restitution', sub: 'Vers 300 ap. J.-C.' }, + { id: 'compare', label: 'Comparer', sub: 'Rideau glissant' }, +] + +export default function VillaDemoPage() { + const canvasRef = useRef(null) + const stageRef = useRef(null) + const markersRef = useRef<(HTMLButtonElement | null)[]>([]) + + const camRef = useRef({ ...VIEW0 }) + const scenesRef = useRef<{ ruins: Face[]; roman: Face[] } | null>(null) + const modeRef = useRef('compare') + const splitRef = useRef(0.48) + const mixRef = useRef(1) + const fromRef = useRef<'ruins' | 'roman'>('ruins') + const toRef = useRef<'ruins' | 'roman'>('roman') + + const [mode, setMode] = useState('compare') + const [panelOpen, setPanelOpen] = useState(true) + const [selected, setSelected] = useState('peristyle') + const [hidden, setHidden] = useState>(new Set()) + + const selectedSpot = HOTSPOTS.find(h => h.id === selected) ?? null + + const changeMode = useCallback((next: Mode) => { + const current = modeRef.current + if (next === current) return + if (next !== 'compare' && current !== 'compare') { + fromRef.current = current as 'ruins' | 'roman' + toRef.current = next as 'ruins' | 'roman' + mixRef.current = 0 + } else { + mixRef.current = 1 + if (next !== 'compare') toRef.current = next as 'ruins' | 'roman' + } + modeRef.current = next + setMode(next) + }, []) + + const toggleSpot = (id: string) => { + setHidden(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else { next.add(id); } + return next + }) + setSelected(prev => (prev === id ? null : prev)) + } + + useEffect(() => { + scenesRef.current = { ruins: buildVilla('ruins'), roman: buildVilla('roman') } + }, []) + + useEffect(() => { + const canvas = canvasRef.current + const stage = stageRef.current + if (!canvas || !stage) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + let w = 0, h = 0, raf = 0, last = 0 + + const resize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const r = stage.getBoundingClientRect() + w = Math.max(320, Math.round(r.width)) + h = Math.max(240, Math.round(r.height)) + canvas.width = Math.round(w * dpr) + canvas.height = Math.round(h * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + + const placeMarkers = () => { + const cam = camRef.current + const compare = modeRef.current === 'compare' + HOTSPOTS.forEach((spot, i) => { + const el = markersRef.current[i] + if (!el) return + const p = compare ? null : project(spot.pos, cam, w, h) + if (!p || p.x < -60 || p.x > w + 60 || p.y < -40 || p.y > h + 40) { + el.style.visibility = 'hidden' + return + } + el.style.visibility = 'visible' + el.style.transform = `translate(${p.x}px, ${p.y}px) translate(-50%, -100%)` + }) + } + + const frame = (t: number) => { + const dt = last ? Math.min(64, t - last) : 16 + last = t + const scenes = scenesRef.current + if (scenes) { + const cam = camRef.current + if (mixRef.current < 1) mixRef.current = Math.min(1, mixRef.current + dt / 520) + drawSky(ctx, w, h) + + if (modeRef.current === 'compare') { + const sx = splitRef.current * w + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, sx, h); ctx.clip() + drawFaces(ctx, scenes.ruins, cam, w, h) + ctx.restore() + ctx.save(); ctx.beginPath(); ctx.rect(sx, 0, w - sx, h); ctx.clip() + drawFaces(ctx, scenes.roman, cam, w, h) + ctx.restore() + ctx.strokeStyle = 'rgba(255,255,255,.92)' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.moveTo(sx, 0); ctx.lineTo(sx, h); ctx.stroke() + ctx.fillStyle = 'rgba(255,255,255,.95)' + ctx.beginPath(); ctx.arc(sx, h / 2, 19, 0, Math.PI * 2); ctx.fill() + ctx.fillStyle = '#6b4a2e' + ctx.font = 'bold 15px system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('↔', sx, h / 2 + 1) + } else { + const mix = mixRef.current + if (mix < 1) drawFaces(ctx, scenes[fromRef.current], cam, w, h, 1 - mix) + drawFaces(ctx, scenes[toRef.current], cam, w, h, mix) + } + } + placeMarkers() + raf = requestAnimationFrame(frame) + } + + const ro = new ResizeObserver(resize) + ro.observe(stage) + resize() + raf = requestAnimationFrame(frame) + return () => { cancelAnimationFrame(raf); ro.disconnect() } + }, []) + + /* Orbite / zoom / rideau — écrits directement dans les refs pour ne pas + réveiller React à chaque image. */ + useEffect(() => { + const stage = stageRef.current + if (!stage) return + let drag: { mode: 'orbit' | 'split'; x: number; y: number } | null = null + let pinch = 0 + + const down = (e: PointerEvent) => { + const target = e.target as HTMLElement + if (target.closest('[data-ui]')) return + const r = stage.getBoundingClientRect() + const fx = (e.clientX - r.left) / r.width + drag = modeRef.current === 'compare' && Math.abs(fx - splitRef.current) < 0.04 + ? { mode: 'split', x: e.clientX, y: e.clientY } + : { mode: 'orbit', x: e.clientX, y: e.clientY } + stage.setPointerCapture(e.pointerId) + } + const move = (e: PointerEvent) => { + if (!drag) return + const cam = camRef.current + if (drag.mode === 'split') { + const r = stage.getBoundingClientRect() + splitRef.current = Math.min(0.9, Math.max(0.1, (e.clientX - r.left) / r.width)) + return + } + cam.yaw += (e.clientX - drag.x) * 0.006 + cam.pitch = Math.min(1.28, Math.max(0.12, cam.pitch + (e.clientY - drag.y) * 0.004)) + drag.x = e.clientX + drag.y = e.clientY + } + const up = () => { drag = null } + const wheel = (e: WheelEvent) => { + e.preventDefault() + const cam = camRef.current + cam.dist = Math.min(520, Math.max(90, cam.dist * (1 + Math.sign(e.deltaY) * 0.09))) + } + const touchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { + drag = null + pinch = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY) + } + } + const touchMove = (e: TouchEvent) => { + if (e.touches.length !== 2 || !pinch) return + const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY) + const cam = camRef.current + cam.dist = Math.min(520, Math.max(90, (cam.dist * pinch) / d)) + pinch = d + } + + stage.addEventListener('pointerdown', down) + stage.addEventListener('pointermove', move) + stage.addEventListener('pointerup', up) + stage.addEventListener('pointercancel', up) + stage.addEventListener('wheel', wheel, { passive: false }) + stage.addEventListener('touchstart', touchStart, { passive: true }) + stage.addEventListener('touchmove', touchMove, { passive: true }) + return () => { + stage.removeEventListener('pointerdown', down) + stage.removeEventListener('pointermove', move) + stage.removeEventListener('pointerup', up) + stage.removeEventListener('pointercancel', up) + stage.removeEventListener('wheel', wheel) + stage.removeEventListener('touchstart', touchStart) + stage.removeEventListener('touchmove', touchMove) + } + }, []) + + const visible = HOTSPOTS.filter(h => !hidden.has(h.id)) + + return ( +
+ + +
+ +
+ history.back()} /> +
+ + {/* Marqueurs ancrés dans la scène */} + {HOTSPOTS.map((spot, i) => { + const isHidden = hidden.has(spot.id) + const isActive = selected === spot.id + return ( + + ) + })} + + {/* Panneau des lieux — même composant que la carte */} +
+ {visible.length} lieux · 2 états} + > +
+ {HOTSPOTS.map(spot => { + const off = hidden.has(spot.id) + return ( +
+ toggleSpot(spot.id)} + className="mt-1 h-4 w-4 shrink-0 accent-[var(--color-primary)]" + aria-label={`Afficher ${spot.title}`} + /> + +
+ ) + })} +
+
+
+ + {/* Fiche du point sélectionné */} + {selectedSpot && !hidden.has(selectedSpot.id) && ( +
+
+ + + {selectedSpot.category} + +
+
+

+ {selectedSpot.title} +

+

+ {selectedSpot.text} +

+ +
+
+ )} + + {/* Étiquettes d'état en mode comparaison */} + {mode === 'compare' && ( + <> + + Aujourd'hui + + + Vers 300 ap. J.-C. + + + )} + + {/* Barre d'états */} +
+ {MODES.map(m => { + const on = mode === m.id + return ( + + ) + })} + + +
+ + + Glisser pour pivoter · molette pour zoomer{mode === 'compare' ? ' · tirer le rideau pour comparer' : ''} + + + {/* Lanceur de l'assistant — présent sur toutes les sections */} +
+ + Une question ? + + + + + + +
+ + + Maquette de démonstration + +
+ ) +} diff --git a/src/app/demo/villa-echternach/scene.ts b/src/app/demo/villa-echternach/scene.ts new file mode 100644 index 0000000..751ceb1 --- /dev/null +++ b/src/app/demo/villa-echternach/scene.ts @@ -0,0 +1,460 @@ +/* Maquette 3D de démonstration — villa romaine d'Echternach. + Géométrie procédurale + rendu Canvas 2D (projection perspective, tri en + profondeur, éclairage par face). Aucune dépendance : la page sert à montrer + au client à quoi ressemblerait une section « scène 3D », pas à charger un + vrai modèle glTF — celui-là viendra d'un studio 3D et se chargera via + `model-viewer`. Les volumes sont donc évocateurs, pas archéologiques. */ + +export type Vec3 = [number, number, number] +export type Face = { pts: Vec3[]; c: string } +export type Camera = { yaw: number; pitch: number; dist: number; tx: number; ty: number; tz: number } +export type VillaState = 'ruins' | 'roman' + +const C = { + render: { + wall: '#efe7d8', + wallShade: '#e3d8c4', + roof: '#b4593c', + roofDark: '#9a4529', + column: '#f6f1e6', + stone: '#cfc4ad', + water: '#7ca7bb', + pavement: '#ded4c0', + }, + ruins: { + stone: '#8d8779', + stoneTop: '#a39c8d', + gravel: '#c37b5b', + column: '#efe9dd', + entablature: '#e4ddcf', + rail: '#4a4f4a', + }, + land: { + lawn: '#8ba46c', + lawnDark: '#7b945d', + hedge: '#4d6a3d', + tree: '#5d7d4a', + trunk: '#6a5741', + river: '#7fa0b4', + path: '#d8cfba', + }, +} + +/* ---------- primitives ---------- */ + +type Pt2 = [number, number] + +function rect(cx: number, cz: number, w: number, d: number): Pt2[] { + const hw = w / 2, hd = d / 2 + return [[cx - hw, cz - hd], [cx + hw, cz - hd], [cx + hw, cz + hd], [cx - hw, cz + hd]] +} + +function ngon(cx: number, cz: number, r: number, n: number, rot = 0): Pt2[] { + const p: Pt2[] = [] + for (let i = 0; i < n; i++) { + const a = rot + (i * 2 * Math.PI) / n + p.push([cx + r * Math.cos(a), cz + r * Math.sin(a)]) + } + return p +} + +function prism(outline: Pt2[], y0: number, y1: number, top: string, side: string): Face[] { + const f: Face[] = [{ pts: outline.map(p => [p[0], y1, p[1]] as Vec3), c: top }] + for (let i = 0; i < outline.length; i++) { + const a = outline[i], b = outline[(i + 1) % outline.length] + f.push({ pts: [[a[0], y0, a[1]], [b[0], y0, b[1]], [b[0], y1, b[1]], [a[0], y1, a[1]]], c: side }) + } + return f +} + +function slab(outline: Pt2[], y: number, c: string): Face[] { + return [{ pts: outline.map(p => [p[0], y, p[1]] as Vec3), c }] +} + +/** Toit en croupe : deux versants trapézoïdaux + deux croupes triangulaires. */ +function hip(cx: number, cz: number, w: number, d: number, h: number, y0: number, c: string): Face[] { + const hw = w / 2, hd = d / 2, y1 = y0 + h + const e = Math.min(hd, hw * 0.34) + const A: Vec3 = [cx - hw, y0, cz - hd], B: Vec3 = [cx + hw, y0, cz - hd] + const D: Vec3 = [cx + hw, y0, cz + hd], E: Vec3 = [cx - hw, y0, cz + hd] + const R1: Vec3 = [cx - hw + e, y1, cz], R2: Vec3 = [cx + hw - e, y1, cz] + return [ + { pts: [A, B, R2, R1], c }, + { pts: [E, D, R2, R1], c }, + { pts: [A, R1, E], c }, + { pts: [B, D, R2], c }, + ] +} + +function column(cx: number, cz: number, r: number, h: number, c: string): Face[] { + return [ + ...prism(rect(cx, cz, r * 2.9, r * 2.9), 0, 0.35, c, c), + ...prism(ngon(cx, cz, r, 6), 0.35, h, c, c), + ...prism(rect(cx, cz, r * 2.9, r * 2.9), h, h + 0.45, c, c), + ] +} + +function colonnadeLine(x0: number, x1: number, z: number, step: number, r: number, h: number, c: string): Face[] { + const f: Face[] = [] + const n = Math.max(1, Math.round(Math.abs(x1 - x0) / step)) + for (let i = 0; i <= n; i++) f.push(...column(x0 + ((x1 - x0) * i) / n, z, r, h, c)) + return f +} + +function colonnadeSide(x: number, z0: number, z1: number, step: number, r: number, h: number, c: string): Face[] { + const f: Face[] = [] + const n = Math.max(1, Math.round(Math.abs(z1 - z0) / step)) + for (let i = 1; i < n; i++) f.push(...column(x, z0 + ((z1 - z0) * i) / n, r, h, c)) + return f +} + +function hedge(cx: number, cz: number, w: number, d: number, h = 1.9): Face[] { + return prism(rect(cx, cz, w, d), 0, h, C.land.hedge, C.land.hedge) +} + +function tree(cx: number, cz: number, h = 9): Face[] { + return [ + ...prism(ngon(cx, cz, 0.5, 5), 0, h * 0.35, C.land.trunk, C.land.trunk), + ...prism(ngon(cx, cz, 2.6, 6), h * 0.3, h * 0.72, C.land.tree, C.land.tree), + ...prism(ngon(cx, cz, 1.6, 6), h * 0.72, h, C.land.tree, C.land.tree), + ] +} + +/* ---------- le site ---------- */ + +/* Plan retenu : cour à péristyle au centre, quatre ailes de pièces autour, + bloc thermal à l'ouest, jardins en terrasse au sud, portique ouvert au nord + vers la vallée de la Sûre. */ +const COURT = { x: 0, z: 0, w: 36, d: 24 } +const WING = 11 + +function terrain(): Face[] { + const f: Face[] = [ + ...slab([[-330, -260], [330, -260], [330, 240], [-330, 240]], -0.6, C.land.lawn), + ...slab([[-330, -260], [330, -260], [330, -120], [-330, -120]], -0.3, C.land.river), + ...slab(rect(0, -95, 420, 26), -0.2, C.land.lawnDark), + ] + return f +} + +function baseCourt(state: VillaState): Face[] { + const p = state === 'roman' ? C.render.pavement : C.ruins.gravel + return slab(rect(COURT.x, COURT.z, COURT.w, COURT.d), 0.06, p) +} + +function wingsRoman(): Face[] { + const hw = COURT.w / 2 + WING, hd = COURT.d / 2 + WING + const f: Face[] = [] + const wallH = 6.4, roofH = 3.6 + // Ailes nord et sud + for (const z of [-(COURT.d / 2 + WING / 2), COURT.d / 2 + WING / 2]) { + f.push(...prism(rect(0, z, hw * 2, WING), 0, wallH, C.render.wall, C.render.wallShade)) + f.push(...hip(0, z, hw * 2 + 1.4, WING + 1.4, roofH, wallH, C.render.roof)) + } + // Ailes est et ouest + for (const x of [-(COURT.w / 2 + WING / 2), COURT.w / 2 + WING / 2]) { + f.push(...prism(rect(x, 0, WING, COURT.d), 0, wallH, C.render.wall, C.render.wallShade)) + f.push(...hip(x, 0, WING + 1.4, COURT.d + 1.4, roofH, wallH, C.render.roof)) + } + // Péristyle : colonnade sur les quatre côtés de la cour + auvent + const cx = COURT.w / 2 - 1.5, cz = COURT.d / 2 - 1.5 + f.push(...colonnadeLine(-cx, cx, -cz, 5, 0.55, 4.4, C.render.column)) + f.push(...colonnadeLine(-cx, cx, cz, 5, 0.55, 4.4, C.render.column)) + f.push(...colonnadeSide(-cx, -cz, cz, 5, 0.55, 4.4, C.render.column)) + f.push(...colonnadeSide(cx, -cz, cz, 5, 0.55, 4.4, C.render.column)) + f.push(...prism(rect(0, -cz, cx * 2 + 3, 3.4), 4.85, 5.5, C.render.roofDark, C.render.wall)) + f.push(...prism(rect(0, cz, cx * 2 + 3, 3.4), 4.85, 5.5, C.render.roofDark, C.render.wall)) + f.push(...prism(rect(-cx, 0, 3.4, cz * 2 - 3), 4.85, 5.5, C.render.roofDark, C.render.wall)) + f.push(...prism(rect(cx, 0, 3.4, cz * 2 - 3), 4.85, 5.5, C.render.roofDark, C.render.wall)) + // Tour d'angle / pavillon d'entrée à l'est + f.push(...prism(rect(hw + 5, 0, 14, 16), 0, 9.5, C.render.wall, C.render.wallShade)) + f.push(...hip(hw + 5, 0, 15.4, 17.4, 4.6, 9.5, C.render.roof)) + return f +} + +function bathsRoman(): Face[] { + const x = -(COURT.w / 2 + WING) - 14 + return [ + ...prism(rect(x, -4, 26, 30), 0, 7.2, C.render.wall, C.render.wallShade), + ...hip(x, -4, 27.4, 31.4, 4.2, 7.2, C.render.roof), + ...prism(ngon(x - 15, -4, 7, 8, Math.PI / 8), 0, 5.6, C.render.wall, C.render.wallShade), + ...prism(ngon(x - 15, -4, 7.8, 8, Math.PI / 8), 5.6, 8.4, C.render.roofDark, C.render.roof), + ...prism(rect(x, 17, 9, 10), 0, 5, C.render.wall, C.render.wallShade), + ...hip(x, 17, 10, 11, 2.8, 5, C.render.roof), + ] +} + +function porticoRoman(): Face[] { + const z = -(COURT.d / 2 + WING) - 7 + return [ + ...slab(rect(0, z, 96, 8), 0.5, C.render.pavement), + ...prism(rect(0, z, 96, 8), 0, 0.5, C.render.stone, C.render.stone), + ...colonnadeLine(-46, 46, z - 2.6, 5.4, 0.6, 5.2, C.render.column), + ...prism(rect(0, z - 2.6, 98, 2.6), 5.65, 6.5, C.render.roofDark, C.render.wall), + ] +} + +function gardensRoman(): Face[] { + const f: Face[] = [...slab(rect(0, 52, 132, 56), 0.12, C.land.lawnDark)] + for (const x of [-26, 26]) { + f.push(...prism(rect(x, 46, 26, 8), 0.12, 1.1, C.render.stone, C.render.stone)) + f.push(...slab(rect(x, 46, 23, 5.6), 0.9, C.render.water)) + } + f.push(...prism(rect(0, 46, 6, 8), 0.12, 1.1, C.render.stone, C.render.stone)) + for (let i = 0; i < 5; i++) { + f.push(...hedge(-52 + i * 26, 66, 20, 2.2, 1.6)) + f.push(...hedge(-52 + i * 26, 76, 20, 2.2, 1.6)) + } + for (const x of [-62, 62]) for (const z of [36, 58, 80]) f.push(...tree(x, z, 10)) + return f +} + +function wingsRuins(): Face[] { + /* Ce qui se voit sur place : arases de murs à hauteur d'homme au plus, + sols de gravier rouge dans les pièces, cloisons devinées au tracé. */ + const hw = COURT.w / 2 + WING, hd = COURT.d / 2 + WING + const f: Face[] = [] + const H = 1.15 + const band = (cx: number, cz: number, w: number, d: number): Face[] => { + const t = 1.1 + return [ + ...slab(rect(cx, cz, w, d), 0.08, C.ruins.gravel), + ...prism(rect(cx, cz - d / 2, w, t), 0, H, C.ruins.stoneTop, C.ruins.stone), + ...prism(rect(cx, cz + d / 2, w, t), 0, H, C.ruins.stoneTop, C.ruins.stone), + ...prism(rect(cx - w / 2, cz, t, d), 0, H, C.ruins.stoneTop, C.ruins.stone), + ...prism(rect(cx + w / 2, cz, t, d), 0, H, C.ruins.stoneTop, C.ruins.stone), + ] + } + for (const z of [-(COURT.d / 2 + WING / 2), COURT.d / 2 + WING / 2]) { + f.push(...band(0, z, hw * 2, WING)) + for (let i = -3; i <= 3; i++) f.push(...prism(rect(i * 13, z, 1.1, WING), 0, H, C.ruins.stoneTop, C.ruins.stone)) + } + for (const x of [-(COURT.w / 2 + WING / 2), COURT.w / 2 + WING / 2]) { + f.push(...band(x, 0, WING, COURT.d)) + for (const z of [-8, 0, 8]) f.push(...prism(rect(x, z, WING, 1.1), 0, H, C.ruins.stoneTop, C.ruins.stone)) + } + f.push(...prism(rect(hw + 5, 0, 14, 16), 0, H, C.ruins.stoneTop, C.ruins.stone)) + f.push(...slab(rect(hw + 5, 0, 12, 14), 0.09, C.ruins.gravel)) + // Stylobate du péristyle : les bases restent, les fûts ont disparu + const cx = COURT.w / 2 - 1.5, cz = COURT.d / 2 - 1.5 + for (const z of [-cz, cz]) f.push(...prism(rect(0, z, cx * 2, 1.6), 0, 0.55, C.ruins.stoneTop, C.ruins.stone)) + for (const x of [-cx, cx]) f.push(...prism(rect(x, 0, 1.6, cz * 2), 0, 0.55, C.ruins.stoneTop, C.ruins.stone)) + return f +} + +function bathsRuins(): Face[] { + const x = -(COURT.w / 2 + WING) - 14 + const H = 1.25 + const f: Face[] = [...slab(rect(x, -4, 26, 30), 0.08, C.ruins.gravel)] + f.push(...prism(rect(x, -19, 26, 1.2), 0, H, C.ruins.stoneTop, C.ruins.stone)) + f.push(...prism(rect(x, 11, 26, 1.2), 0, H, C.ruins.stoneTop, C.ruins.stone)) + f.push(...prism(rect(x - 13, -4, 1.2, 30), 0, H, C.ruins.stoneTop, C.ruins.stone)) + f.push(...prism(rect(x + 13, -4, 1.2, 30), 0, H, C.ruins.stoneTop, C.ruins.stone)) + for (const z of [-12, -4, 4]) f.push(...prism(rect(x, z, 26, 1.2), 0, H, C.ruins.stoneTop, C.ruins.stone)) + f.push(...prism(ngon(x - 15, -4, 7, 8, Math.PI / 8), 0, 0.9, C.ruins.stoneTop, C.ruins.stone)) + // Hypocauste : pilettes de briques sous le sol de la salle chaude + for (let i = 0; i < 4; i++) + for (let j = 0; j < 3; j++) + f.push(...prism(rect(x - 8 + i * 5.5, -9 + j * 4.5, 1.3, 1.3), 0.08, 0.8, '#a35b42', '#8f4c36')) + return f +} + +function porticoRuins(): Face[] { + /* Le seul volume relevé sur le site : une file de colonnes remontées avec + son entablement, exactement le repère visuel des photos. */ + const z = -(COURT.d / 2 + WING) - 7 + return [ + ...slab(rect(0, z, 96, 8), 0.4, C.ruins.gravel), + ...prism(rect(0, z, 96, 8), 0, 0.4, C.ruins.stoneTop, C.ruins.stone), + ...colonnadeLine(-46, 46, z - 2.6, 5.4, 0.6, 5.2, C.ruins.column), + ...prism(rect(0, z - 2.6, 98, 2.2), 5.65, 6.4, C.ruins.entablature, C.ruins.entablature), + ] +} + +function gardensRuins(): Face[] { + const f: Face[] = [...slab(rect(0, 52, 132, 56), 0.12, C.land.lawn)] + for (const x of [-26, 26]) { + f.push(...prism(rect(x, 46, 26, 8), 0.12, 0.75, C.ruins.stoneTop, C.ruins.stone)) + f.push(...slab(rect(x, 46, 23, 5.6), 0.5, C.ruins.gravel)) + } + // Haies et pelouse tondue : la mise en valeur contemporaine du site + for (const x of [-70, 70]) f.push(...hedge(x, 30, 4, 120, 2.4)) + f.push(...hedge(0, 92, 144, 4, 2.4)) + for (const x of [-58, -20, 20, 58]) f.push(...tree(x, 74, 11)) + f.push(...slab(rect(0, 20, 150, 4), 0.14, C.land.path)) + f.push(...slab(rect(-60, 40, 4, 44), 0.14, C.land.path)) + return f +} + +export function buildVilla(state: VillaState): Face[] { + if (state === 'roman') { + return [ + ...terrain(), + ...gardensRoman(), + ...baseCourt('roman'), + ...porticoRoman(), + ...bathsRoman(), + ...wingsRoman(), + ] + } + return [ + ...terrain(), + ...gardensRuins(), + ...baseCourt('ruins'), + ...porticoRuins(), + ...bathsRuins(), + ...wingsRuins(), + ] +} + +/* ---------- points d'intérêt ---------- */ + +export type Hotspot = { + id: string + title: string + category: string + teaser: string + text: string + pos: Vec3 +} + +export const HOTSPOTS: Hotspot[] = [ + { + id: 'peristyle', + title: 'La cour à péristyle', + category: 'Le bâtiment', + teaser: 'Le cœur de la villa et sa galerie de colonnes.', + text: "Toutes les pièces de réception ouvraient sur cette cour couverte d'une galerie. Aujourd'hui seules subsistent les fondations et le socle continu qui portait les colonnes : c'est ce vide central que la restitution remplit.", + pos: [0, 5, 0], + }, + { + id: 'thermes', + title: 'Les thermes', + category: 'Le bâtiment', + teaser: 'Salles froide, tiède et chaude, chauffées par le sol.', + text: "Le bloc thermal occupe l'aile ouest, avec son abside et son hypocauste — ces piles de briques qui laissaient circuler l'air chaud sous le pavement. Les pilettes sont encore en place sur le site.", + pos: [-61, 4, -4], + }, + { + id: 'jardins', + title: 'Les jardins en terrasse', + category: 'Extérieurs', + teaser: "Bassins d'agrément et parterres descendant vers le sud.", + text: "Deux longs bassins encadraient l'axe du jardin, en contrebas des pièces de réception. Le tracé se lit toujours au sol : la restitution leur rend leur eau et leurs parterres.", + pos: [-26, 3, 46], + }, + { + id: 'portique', + title: 'Le portique sur la vallée', + category: 'Panorama', + teaser: 'La façade ouverte sur la Sûre et sa voie.', + text: "La villa tournait sa plus belle façade vers la vallée. C'est la seule colonnade remontée sur le site : elle sert de repère pour passer d'un état à l'autre.", + pos: [0, 7.5, -48], + }, +] + +/* ---------- rendu ---------- */ + +const LIGHT = ((): Vec3 => { + const v: Vec3 = [-0.38, 0.87, -0.31] + const m = Math.hypot(v[0], v[1], v[2]) + return [v[0] / m, v[1] / m, v[2] / m] +})() + +export function cameraPosition(cam: Camera): Vec3 { + const cp = Math.cos(cam.pitch), sp = Math.sin(cam.pitch) + const cy = Math.cos(cam.yaw), sy = Math.sin(cam.yaw) + return [cam.tx - cam.dist * sy * cp, cam.ty + cam.dist * sp, cam.tz - cam.dist * cy * cp] +} + +export function project(p: Vec3, cam: Camera, w: number, h: number) { + const x = p[0] - cam.tx, y = p[1] - cam.ty, z = p[2] - cam.tz + const cy = Math.cos(cam.yaw), sy = Math.sin(cam.yaw) + const x1 = x * cy - z * sy, z1 = x * sy + z * cy + const cp = Math.cos(cam.pitch), sp = Math.sin(cam.pitch) + const y2 = y * cp + z1 * sp, z2 = z1 * cp - y * sp + const zc = z2 + cam.dist + if (zc < 6) return null + const f = 0.95 * Math.min(w, h * 1.6) + return { x: w / 2 + (x1 * f) / zc, y: h / 2 - (y2 * f) / zc, z: zc } +} + +const shadeCache = new Map() +function shade(hex: string, k: number) { + const q = Math.round(k * 32) / 32 + const key = `${hex}|${q}` + const hit = shadeCache.get(key) + if (hit) return hit + const n = parseInt(hex.slice(1), 16) + const r = Math.min(255, Math.round(((n >> 16) & 255) * q)) + const g = Math.min(255, Math.round(((n >> 8) & 255) * q)) + const b = Math.min(255, Math.round((n & 255) * q)) + const out = `rgb(${r},${g},${b})` + shadeCache.set(key, out) + return out +} + +export function drawFaces( + ctx: CanvasRenderingContext2D, + faces: Face[], + cam: Camera, + w: number, + h: number, + alpha = 1, +) { + const cp = cameraPosition(cam) + const out: { pts: { x: number; y: number }[]; fill: string; z: number }[] = [] + + for (const f of faces) { + const pr: { x: number; y: number; z: number }[] = [] + let ok = true + for (const p of f.pts) { + const q = project(p, cam, w, h) + if (!q) { ok = false; break } + pr.push(q) + } + if (!ok) continue + + const [p0, p1, p2] = f.pts + const ax = p1[0] - p0[0], ay = p1[1] - p0[1], az = p1[2] - p0[2] + const bx = p2[0] - p0[0], by = p2[1] - p0[1], bz = p2[2] - p0[2] + let nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx + const ln = Math.hypot(nx, ny, nz) || 1 + nx /= ln; ny /= ln; nz /= ln + + let cxw = 0, cyw = 0, czw = 0 + for (const p of f.pts) { cxw += p[0]; cyw += p[1]; czw += p[2] } + cxw /= f.pts.length; cyw /= f.pts.length; czw /= f.pts.length + if ((cp[0] - cxw) * nx + (cp[1] - cyw) * ny + (cp[2] - czw) * nz < 0) { nx = -nx; ny = -ny; nz = -nz } + + const dot = nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2] + let zavg = 0 + for (const q of pr) zavg += q.z + out.push({ pts: pr, fill: shade(f.c, 0.6 + 0.5 * Math.max(0, dot)), z: zavg / pr.length }) + } + + out.sort((a, b) => b.z - a.z) + ctx.globalAlpha = alpha + ctx.lineJoin = 'round' + for (const o of out) { + ctx.beginPath() + ctx.moveTo(o.pts[0].x, o.pts[0].y) + for (let i = 1; i < o.pts.length; i++) ctx.lineTo(o.pts[i].x, o.pts[i].y) + ctx.closePath() + ctx.fillStyle = o.fill + ctx.fill() + ctx.strokeStyle = 'rgba(40,34,26,.08)' + ctx.lineWidth = 1 + ctx.stroke() + } + ctx.globalAlpha = 1 +} + +export function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) { + const g = ctx.createLinearGradient(0, 0, 0, h) + g.addColorStop(0, '#c9d6dd') + g.addColorStop(0.5, '#dfe3dc') + g.addColorStop(1, '#c2c9b4') + ctx.fillStyle = g + ctx.fillRect(0, 0, w, h) +} diff --git a/src/app/globals.css b/src/app/globals.css index 2e9c4f5..1450d79 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,6 +8,16 @@ --color-text-muted: #6b7280; --color-border: #e5e7eb; + /* Palette neutre partagee des sections : papier creme + encre bleu ardoise. + Independante des couleurs d'instance, portees par --color-primary. */ + --color-paper: #f6f3ee; + --color-paper-soft: #efeae2; + --color-paper-accent: #e8e3da; + --color-paper-border: #e2dcd2; + --color-ink: #1e2a33; + --color-ink-soft: #46555f; + --color-ink-muted: #54636e; + /* Dynamic tokens — overridden per instance/config via inline style on */ --color-primary: #264863; --color-secondary: #c2c9d6; @@ -25,6 +35,13 @@ --color-text: var(--color-text); --color-text-muted: var(--color-text-muted); --color-border: var(--color-border); + --color-paper: var(--color-paper); + --color-paper-soft: var(--color-paper-soft); + --color-paper-accent: var(--color-paper-accent); + --color-paper-border: var(--color-paper-border); + --color-ink: var(--color-ink); + --color-ink-soft: var(--color-ink-soft); + --color-ink-muted: var(--color-ink-muted); } body { @@ -32,3 +49,51 @@ body { color: var(--color-text); font-family: Arial, Helvetica, sans-serif; } + +/* ── Encombrement des calques flottants ──────────────────────────────────── + Le lanceur de l'assistant occupe le coin bas-droit sur toutes les sections + (monté dans [configId]/layout.tsx). Toute section qui pose un bouton ou une + barre flottante s'écarte via ces deux utilitaires plutôt qu'en recalculant + des coordonnées dans son coin — sinon la collision revient à la section + suivante. Sans assistant, les insets valent 0 et rien ne bouge. */ +:root { + --mim-assistant-inset-y: 0px; + --mim-assistant-inset-x: 0px; +} +[data-assistant='true'] { + /* 54px de lanceur + 12px de respiration ; en x on ajoute la marge droite. */ + --mim-assistant-inset-y: 66px; + --mim-assistant-inset-x: 82px; +} + +/* Élément ancré en bas à droite : il se range au-dessus du lanceur. + `margin-bottom` décale un élément en position absolue/fixe qui pose `bottom`. */ +.mim-clear-assistant { margin-bottom: var(--mim-assistant-inset-y); } + +/* Une barre pleine largeur ne peut pas se décaler sans laisser un trou : elle + compose plutôt l'inset dans son propre padding, d'où les variables ci-dessus. */ + +/* Bouton flottant du scanner QR — même gabarit que le lanceur de l'assistant. + Quand l'assistant est actif, il occupe le coin bas-droit et le scanner se + range juste au-dessus. */ +.mim-fab-qr { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 1200; + width: 54px; + height: 54px; + border-radius: 999px; + border: none; + display: grid; + place-items: center; + cursor: pointer; + background: var(--color-surface); + color: var(--color-primary); + box-shadow: 0 10px 26px -8px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .18); + transition: transform .22s cubic-bezier(.2, .7, .3, 1); +} +.mim-fab-qr:hover { transform: translateY(-2px) scale(1.04); } +.mim-fab-qr svg { width: 26px; height: 26px; } + +[data-assistant='true'] .mim-fab-qr { bottom: calc(20px + var(--mim-assistant-inset-y)); } diff --git a/src/components/QRScannerButton.tsx b/src/components/QRScannerButton.tsx index 8c21965..d7cd0e2 100644 --- a/src/components/QRScannerButton.tsx +++ b/src/components/QRScannerButton.tsx @@ -92,12 +92,7 @@ export default function QRScannerButton({ slug, configurationId }: Props) { <> +
+ GUIDE AUDIO + {!isAudioCompact && ( +
+ +
+ )} +
+ + {duration > 0 ? formatTime(duration) : '--:--'} + +
+ {/* La variante compacte masque la waveform : la barre reste son seul indicateur. */} + {isAudioCompact && duration > 0 && ( +
+
+
+ )} +
+ ) return ( -
+
-
- {/* Carousel images */} - {contents.length > 0 && ( - - )} - - {/* HTML content */} - {htmlContent && ( -
- )} -
- - {/* Floating audio player */} {audioUrl && ( - <> -