# Story 3.6: Carte interactive desktop (Konva.js)

Status: review

## 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

