Files
Portfolio-Game/docs/implementation-artifacts/3-6-carte-interactive-desktop-konvajs.md
skycel ec1ae92799 🎉 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>
2026-02-05 02:08:56 +01:00

19 KiB

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

# Dans le dossier frontend
pnpm add konva vue-konva

Nuxt Config (Konva SSR-safe)

// nuxt.config.ts
export default defineNuxtConfig({
  // ...
  build: {
    transpile: ['konva', 'vue-konva'],
  },
})

Définition des zones

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

<!-- 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 :

{
  "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 :

{
  "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

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

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