🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet : - Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS) - Backend Laravel 12 API-only avec middleware X-API-Key et CORS - Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter) - Documentation planning et implementation artifacts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
# Story 3.6: Carte interactive desktop (Konva.js)
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur desktop,
|
||||
I want naviguer via une carte interactive visuelle,
|
||||
so that j'explore librement le portfolio comme un monde.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur est sur desktop (>= 1024px) et accède à la carte **When** la carte se charge **Then** un canvas Konva.js affiche une carte stylisée avec les zones (Projets, Compétences, Parcours, Témoignages, Contact)
|
||||
2. **And** le composant est chargé en lazy-loading (`.client.vue`) pour respecter le budget JS
|
||||
3. **And** chaque zone a une apparence distincte (teinte unique, icône)
|
||||
4. **And** les zones visitées ont une apparence différente des zones non visitées
|
||||
5. **And** la zone Contact est verrouillée visuellement si `contactUnlocked` est `false`
|
||||
6. **And** la position actuelle du visiteur est marquée sur la carte
|
||||
7. **And** au hover sur une zone : le nom et le statut s'affichent (tooltip)
|
||||
8. **And** au clic sur une zone : navigation vers la section correspondante avec transition
|
||||
9. **And** un curseur personnalisé indique les zones cliquables
|
||||
10. **And** la navigation au clavier est fonctionnelle (Tab entre zones, Enter pour naviguer)
|
||||
11. **And** les zones ont des labels ARIA descriptifs
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Installer et configurer Konva.js** (AC: #2)
|
||||
- [ ] Installer `konva` et `vue-konva`
|
||||
- [ ] Configurer pour Nuxt (SSR-safe)
|
||||
- [ ] Créer le wrapper `.client.vue` pour lazy-loading
|
||||
|
||||
- [ ] **Task 2: Définir la structure des zones** (AC: #1, #3)
|
||||
- [ ] Créer les données des 5 zones : projets, competences, parcours, temoignages, contact
|
||||
- [ ] Pour chaque zone : position (x, y), couleur, icône, label, route
|
||||
- [ ] Design en forme d'île/territoire stylisé
|
||||
|
||||
- [ ] **Task 3: Créer le composant InteractiveMap** (AC: #1, #2)
|
||||
- [ ] Créer `frontend/app/components/feature/InteractiveMap.client.vue`
|
||||
- [ ] Initialiser le Stage et Layer Konva
|
||||
- [ ] Dessiner le fond de carte (texture, grille, etc.)
|
||||
- [ ] Placer les zones selon les positions définies
|
||||
|
||||
- [ ] **Task 4: Implémenter les états visuels des zones** (AC: #3, #4, #5)
|
||||
- [ ] Zone non visitée : couleur atténuée, opacité réduite
|
||||
- [ ] Zone visitée : couleur vive, checkmark ou brillance
|
||||
- [ ] Zone Contact verrouillée : effet grisé + icône cadenas
|
||||
- [ ] Zone Contact débloquée : brillance, invitation visuelle
|
||||
|
||||
- [ ] **Task 5: Implémenter le marqueur de position** (AC: #6)
|
||||
- [ ] Créer un marqueur animé (pulsation)
|
||||
- [ ] Positionner sur la zone actuelle (basé sur la route)
|
||||
- [ ] Animer le déplacement entre zones
|
||||
|
||||
- [ ] **Task 6: Implémenter les interactions hover** (AC: #7, #9)
|
||||
- [ ] Détecter le hover sur chaque zone
|
||||
- [ ] Afficher un tooltip avec nom + statut
|
||||
- [ ] Changer le curseur en pointer
|
||||
- [ ] Effet de surbrillance sur la zone
|
||||
|
||||
- [ ] **Task 7: Implémenter les interactions clic** (AC: #8)
|
||||
- [ ] Détecter le clic sur une zone
|
||||
- [ ] Si zone accessible : naviguer avec router.push()
|
||||
- [ ] Si zone Contact verrouillée : afficher message ou shake
|
||||
- [ ] Animation de transition (zoom ou fade)
|
||||
|
||||
- [ ] **Task 8: Implémenter l'accessibilité** (AC: #10, #11)
|
||||
- [ ] Rendre les zones focusables (tabindex)
|
||||
- [ ] Gérer Tab pour naviguer entre zones
|
||||
- [ ] Gérer Enter/Space pour cliquer
|
||||
- [ ] Ajouter aria-label descriptif à chaque zone
|
||||
- [ ] Ajouter role="button" aux zones cliquables
|
||||
|
||||
- [ ] **Task 9: Responsive et performance**
|
||||
- [ ] Masquer la carte sous 1024px (afficher alternative mobile)
|
||||
- [ ] Optimiser les redessins (cache les images)
|
||||
- [ ] Lazy-load les images des zones
|
||||
|
||||
- [ ] **Task 10: Tests et validation**
|
||||
- [ ] Tester le chargement lazy
|
||||
- [ ] Vérifier les 5 zones distinctes
|
||||
- [ ] Tester les états (visité/non visité/verrouillé)
|
||||
- [ ] Valider hover et clic
|
||||
- [ ] Tester navigation clavier
|
||||
- [ ] Vérifier accessibilité (screen reader)
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Installation de Konva
|
||||
|
||||
```bash
|
||||
# Dans le dossier frontend
|
||||
pnpm add konva vue-konva
|
||||
```
|
||||
|
||||
### Nuxt Config (Konva SSR-safe)
|
||||
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
export default defineNuxtConfig({
|
||||
// ...
|
||||
build: {
|
||||
transpile: ['konva', 'vue-konva'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Définition des zones
|
||||
|
||||
```typescript
|
||||
// frontend/app/data/mapZones.ts
|
||||
import type { Section } from '~/stores/progression'
|
||||
|
||||
export interface MapZone {
|
||||
id: Section | 'contact'
|
||||
label: {
|
||||
fr: string
|
||||
en: string
|
||||
}
|
||||
route: {
|
||||
fr: string
|
||||
en: string
|
||||
}
|
||||
position: { x: number; y: number }
|
||||
color: string
|
||||
icon: string // URL ou emoji
|
||||
size: number // rayon ou taille
|
||||
}
|
||||
|
||||
export const mapZones: MapZone[] = [
|
||||
{
|
||||
id: 'projets',
|
||||
label: { fr: 'Projets', en: 'Projects' },
|
||||
route: { fr: '/projets', en: '/en/projects' },
|
||||
position: { x: 200, y: 150 },
|
||||
color: '#3b82f6', // blue-500
|
||||
icon: '/images/map/icon-projects.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'competences',
|
||||
label: { fr: 'Compétences', en: 'Skills' },
|
||||
route: { fr: '/competences', en: '/en/skills' },
|
||||
position: { x: 450, y: 120 },
|
||||
color: '#10b981', // emerald-500
|
||||
icon: '/images/map/icon-skills.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'temoignages',
|
||||
label: { fr: 'Témoignages', en: 'Testimonials' },
|
||||
route: { fr: '/temoignages', en: '/en/testimonials' },
|
||||
position: { x: 350, y: 280 },
|
||||
color: '#f59e0b', // amber-500
|
||||
icon: '/images/map/icon-testimonials.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'parcours',
|
||||
label: { fr: 'Parcours', en: 'Journey' },
|
||||
route: { fr: '/parcours', en: '/en/journey' },
|
||||
position: { x: 550, y: 300 },
|
||||
color: '#8b5cf6', // violet-500
|
||||
icon: '/images/map/icon-journey.svg',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
label: { fr: 'Contact', en: 'Contact' },
|
||||
route: { fr: '/contact', en: '/en/contact' },
|
||||
position: { x: 650, y: 180 },
|
||||
color: '#fa784f', // sky-accent
|
||||
icon: '/images/map/icon-contact.svg',
|
||||
size: 80,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Composant InteractiveMap
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/InteractiveMap.client.vue -->
|
||||
<script setup lang="ts">
|
||||
import Konva from 'konva'
|
||||
import { mapZones, type MapZone } from '~/data/mapZones'
|
||||
|
||||
const props = defineProps<{
|
||||
currentSection?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [zone: MapZone]
|
||||
}>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const stageRef = ref<Konva.Stage | null>(null)
|
||||
|
||||
// Dimensions du canvas
|
||||
const CANVAS_WIDTH = 800
|
||||
const CANVAS_HEIGHT = 500
|
||||
|
||||
// État du hover
|
||||
const hoveredZone = ref<MapZone | null>(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// Zone focusée (pour clavier)
|
||||
const focusedZoneIndex = ref(-1)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
})
|
||||
|
||||
function initCanvas() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const stage = new Konva.Stage({
|
||||
container: containerRef.value,
|
||||
width: CANVAS_WIDTH,
|
||||
height: CANVAS_HEIGHT,
|
||||
})
|
||||
|
||||
stageRef.value = stage
|
||||
|
||||
// Layer de fond
|
||||
const backgroundLayer = new Konva.Layer()
|
||||
drawBackground(backgroundLayer)
|
||||
stage.add(backgroundLayer)
|
||||
|
||||
// Layer des zones
|
||||
const zonesLayer = new Konva.Layer()
|
||||
drawZones(zonesLayer)
|
||||
stage.add(zonesLayer)
|
||||
|
||||
// Layer du marqueur de position
|
||||
const markerLayer = new Konva.Layer()
|
||||
drawPositionMarker(markerLayer)
|
||||
stage.add(markerLayer)
|
||||
}
|
||||
|
||||
function drawBackground(layer: Konva.Layer) {
|
||||
// Fond avec texture (grille ou motif)
|
||||
const background = new Konva.Rect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: CANVAS_WIDTH,
|
||||
height: CANVAS_HEIGHT,
|
||||
fill: '#0f172a', // sky-dark
|
||||
})
|
||||
layer.add(background)
|
||||
|
||||
// Lignes de connexion entre zones (chemins)
|
||||
const connections = [
|
||||
['projets', 'competences'],
|
||||
['competences', 'temoignages'],
|
||||
['temoignages', 'parcours'],
|
||||
['parcours', 'contact'],
|
||||
['projets', 'temoignages'],
|
||||
]
|
||||
|
||||
connections.forEach(([from, to]) => {
|
||||
const zoneFrom = mapZones.find(z => z.id === from)
|
||||
const zoneTo = mapZones.find(z => z.id === to)
|
||||
if (zoneFrom && zoneTo) {
|
||||
const line = new Konva.Line({
|
||||
points: [zoneFrom.position.x, zoneFrom.position.y, zoneTo.position.x, zoneTo.position.y],
|
||||
stroke: '#334155', // sky-dark-100
|
||||
strokeWidth: 2,
|
||||
dash: [10, 5],
|
||||
opacity: 0.5,
|
||||
})
|
||||
layer.add(line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawZones(layer: Konva.Layer) {
|
||||
mapZones.forEach((zone, index) => {
|
||||
const isVisited = zone.id !== 'contact' && progressionStore.visitedSections.includes(zone.id)
|
||||
const isLocked = zone.id === 'contact' && !progressionStore.contactUnlocked
|
||||
const isCurrent = zone.id === props.currentSection
|
||||
|
||||
// Groupe pour la zone
|
||||
const group = new Konva.Group({
|
||||
x: zone.position.x,
|
||||
y: zone.position.y,
|
||||
})
|
||||
|
||||
// Cercle de la zone
|
||||
const circle = new Konva.Circle({
|
||||
radius: zone.size,
|
||||
fill: isLocked ? '#475569' : zone.color,
|
||||
opacity: isVisited ? 1 : 0.6,
|
||||
shadowColor: zone.color,
|
||||
shadowBlur: isVisited ? 20 : 0,
|
||||
shadowOpacity: 0.5,
|
||||
})
|
||||
group.add(circle)
|
||||
|
||||
// Icône (texte emoji pour simplifier, ou image)
|
||||
const icon = new Konva.Text({
|
||||
text: isLocked ? '🔒' : getZoneEmoji(zone.id),
|
||||
fontSize: 32,
|
||||
x: -16,
|
||||
y: -16,
|
||||
})
|
||||
group.add(icon)
|
||||
|
||||
// Checkmark si visité
|
||||
if (isVisited && zone.id !== 'contact') {
|
||||
const check = new Konva.Text({
|
||||
text: '✓',
|
||||
fontSize: 24,
|
||||
fill: '#22c55e',
|
||||
x: zone.size - 20,
|
||||
y: -zone.size,
|
||||
})
|
||||
group.add(check)
|
||||
}
|
||||
|
||||
// Événements
|
||||
group.on('mouseenter', () => {
|
||||
document.body.style.cursor = 'pointer'
|
||||
hoveredZone.value = zone
|
||||
tooltipPosition.value = {
|
||||
x: zone.position.x,
|
||||
y: zone.position.y - zone.size - 20,
|
||||
}
|
||||
// Effet hover
|
||||
circle.shadowBlur(30)
|
||||
layer.draw()
|
||||
})
|
||||
|
||||
group.on('mouseleave', () => {
|
||||
document.body.style.cursor = 'default'
|
||||
hoveredZone.value = null
|
||||
circle.shadowBlur(isVisited ? 20 : 0)
|
||||
layer.draw()
|
||||
})
|
||||
|
||||
group.on('click tap', () => {
|
||||
handleZoneClick(zone)
|
||||
})
|
||||
|
||||
layer.add(group)
|
||||
})
|
||||
}
|
||||
|
||||
function drawPositionMarker(layer: Konva.Layer) {
|
||||
if (!props.currentSection) return
|
||||
|
||||
const currentZone = mapZones.find(z => z.id === props.currentSection)
|
||||
if (!currentZone) return
|
||||
|
||||
// Marqueur pulsant
|
||||
const marker = new Konva.Circle({
|
||||
x: currentZone.position.x,
|
||||
y: currentZone.position.y,
|
||||
radius: 10,
|
||||
fill: '#fa784f', // sky-accent
|
||||
opacity: 1,
|
||||
})
|
||||
|
||||
// Animation de pulsation
|
||||
const anim = new Konva.Animation((frame) => {
|
||||
if (!frame) return
|
||||
const scale = 1 + 0.3 * Math.sin(frame.time / 200)
|
||||
marker.scale({ x: scale, y: scale })
|
||||
marker.opacity(1 - 0.3 * Math.abs(Math.sin(frame.time / 200)))
|
||||
}, layer)
|
||||
|
||||
anim.start()
|
||||
layer.add(marker)
|
||||
}
|
||||
|
||||
function getZoneEmoji(id: string): string {
|
||||
const emojis: Record<string, string> = {
|
||||
projets: '💻',
|
||||
competences: '⚡',
|
||||
temoignages: '💬',
|
||||
parcours: '📍',
|
||||
contact: '📧',
|
||||
}
|
||||
return emojis[id] || '?'
|
||||
}
|
||||
|
||||
function handleZoneClick(zone: MapZone) {
|
||||
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
|
||||
// Zone verrouillée - afficher message ou shake
|
||||
// TODO: Animation shake ou notification
|
||||
return
|
||||
}
|
||||
|
||||
const route = locale.value === 'fr' ? zone.route.fr : zone.route.en
|
||||
router.push(route)
|
||||
emit('navigate', zone)
|
||||
}
|
||||
|
||||
// Navigation clavier
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
focusedZoneIndex.value = (focusedZoneIndex.value - 1 + mapZones.length) % mapZones.length
|
||||
} else {
|
||||
focusedZoneIndex.value = (focusedZoneIndex.value + 1) % mapZones.length
|
||||
}
|
||||
highlightFocusedZone()
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (focusedZoneIndex.value >= 0) {
|
||||
handleZoneClick(mapZones[focusedZoneIndex.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlightFocusedZone() {
|
||||
// Mettre en surbrillance la zone focusée
|
||||
hoveredZone.value = mapZones[focusedZoneIndex.value]
|
||||
const zone = mapZones[focusedZoneIndex.value]
|
||||
tooltipPosition.value = {
|
||||
x: zone.position.x,
|
||||
y: zone.position.y - zone.size - 20,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="interactive-map-container relative"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
role="application"
|
||||
:aria-label="$t('map.ariaLabel')"
|
||||
>
|
||||
<!-- Canvas Konva -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="konva-container rounded-xl overflow-hidden shadow-2xl"
|
||||
></div>
|
||||
|
||||
<!-- Tooltip HTML (au-dessus du canvas) -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredZone"
|
||||
class="tooltip absolute pointer-events-none z-10 bg-sky-dark-50 border border-sky-dark-100 rounded-lg px-3 py-2 shadow-lg"
|
||||
:style="{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
top: `${tooltipPosition.y}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}"
|
||||
>
|
||||
<p class="font-ui font-semibold text-sky-text">
|
||||
{{ locale === 'fr' ? hoveredZone.label.fr : hoveredZone.label.en }}
|
||||
</p>
|
||||
<p class="text-xs text-sky-text-muted">
|
||||
<template v-if="hoveredZone.id === 'contact' && !progressionStore.contactUnlocked">
|
||||
{{ $t('map.locked') }}
|
||||
</template>
|
||||
<template v-else-if="hoveredZone.id !== 'contact' && progressionStore.visitedSections.includes(hoveredZone.id)">
|
||||
{{ $t('map.visited') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('map.clickToExplore') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="legend absolute bottom-4 left-4 bg-sky-dark-50/80 backdrop-blur rounded-lg p-3">
|
||||
<div class="flex items-center gap-4 text-xs font-ui">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-sky-accent opacity-60"></span>
|
||||
<span class="text-sky-text-muted">{{ $t('map.legend.notVisited') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-sky-accent shadow-lg shadow-sky-accent/50"></span>
|
||||
<span class="text-sky-text-muted">{{ $t('map.legend.visited') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-3 h-3 rounded-full bg-gray-500"></span>
|
||||
<span class="text-sky-text-muted">{{ $t('map.legend.locked') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<p class="sr-only">
|
||||
{{ $t('map.instructions') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.interactive-map-container {
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.interactive-map-container:focus {
|
||||
outline: 2px solid var(--sky-accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"map": {
|
||||
"ariaLabel": "Carte interactive du portfolio. Utilisez Tab pour naviguer entre les zones et Entrée pour explorer.",
|
||||
"instructions": "Utilisez les touches Tab pour naviguer entre les zones et Entrée ou Espace pour explorer une zone.",
|
||||
"locked": "Zone verrouillée - Explorez davantage pour débloquer",
|
||||
"visited": "Déjà visité",
|
||||
"clickToExplore": "Cliquez pour explorer",
|
||||
"legend": {
|
||||
"notVisited": "Non visité",
|
||||
"visited": "Visité",
|
||||
"locked": "Verrouillé"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"map": {
|
||||
"ariaLabel": "Interactive portfolio map. Use Tab to navigate between zones and Enter to explore.",
|
||||
"instructions": "Use Tab keys to navigate between zones and Enter or Space to explore a zone.",
|
||||
"locked": "Locked zone - Explore more to unlock",
|
||||
"visited": "Already visited",
|
||||
"clickToExplore": "Click to explore",
|
||||
"legend": {
|
||||
"notVisited": "Not visited",
|
||||
"visited": "Visited",
|
||||
"locked": "Locked"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation dans une page
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/carte.vue ou dans le layout -->
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
|
||||
// Déterminer la section actuelle basée sur la route
|
||||
const currentSection = computed(() => {
|
||||
const path = route.path
|
||||
if (path.includes('projets') || path.includes('projects')) return 'projets'
|
||||
if (path.includes('competences') || path.includes('skills')) return 'competences'
|
||||
if (path.includes('temoignages') || path.includes('testimonials')) return 'temoignages'
|
||||
if (path.includes('parcours') || path.includes('journey')) return 'parcours'
|
||||
if (path.includes('contact')) return 'contact'
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center py-8">
|
||||
<!-- Carte visible uniquement sur desktop -->
|
||||
<ClientOnly>
|
||||
<InteractiveMap
|
||||
:current-section="currentSection"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.5 : Store de progression (visitedSections, contactUnlocked)
|
||||
- Nuxt/Vue 3 avec support Konva
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 3.7 : Navigation mobile (alternative à la carte)
|
||||
- Story 4.2 : Intro narrative (peut utiliser la carte)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/
|
||||
├── app/
|
||||
│ ├── components/feature/
|
||||
│ │ └── InteractiveMap.client.vue # CRÉER
|
||||
│ └── data/
|
||||
│ └── mapZones.ts # CRÉER
|
||||
└── public/images/map/
|
||||
├── icon-projects.svg # CRÉER (optionnel)
|
||||
├── icon-skills.svg # CRÉER (optionnel)
|
||||
├── icon-testimonials.svg # CRÉER (optionnel)
|
||||
├── icon-journey.svg # CRÉER (optionnel)
|
||||
└── icon-contact.svg # CRÉER (optionnel)
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
frontend/package.json # AJOUTER konva, vue-konva
|
||||
frontend/nuxt.config.ts # AJOUTER transpile konva
|
||||
frontend/i18n/fr.json # AJOUTER map.*
|
||||
frontend/i18n/en.json # AJOUTER map.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-3.6]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Interactive-Map]
|
||||
- [Source: docs/planning-artifacts/architecture.md#JS-Budget]
|
||||
- [Konva.js Documentation](https://konvajs.org/)
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Breakpoint desktop | >= 1024px | Epics |
|
||||
| Bibliothèque canvas | Konva.js + vue-konva | Architecture |
|
||||
| Chargement | Lazy (.client.vue) | JS Budget |
|
||||
| Zones | 5 (projets, competences, temoignages, parcours, contact) | Epics |
|
||||
| Accessibilité | Tab + Enter/Space, ARIA | Epics |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
Reference in New Issue
Block a user