feat(frontend): carte interactive desktop avec Konva.js

Story 3.6 : Carte interactive desktop (Konva.js)
- Installation de konva et vue-konva
- Configuration nuxt.config.ts pour transpile Konva
- Création mapZones.ts avec 5 zones et connexions
- Composant InteractiveMap.client.vue :
  - Canvas Konva avec zones cliquables
  - États visuels (visité/non visité/verrouillé)
  - Tooltip au hover avec statut
  - Marqueur de position animé
  - Navigation clavier (Tab + Enter)
  - Légende interactive
- Traductions map.* FR/EN
- Lazy-loading client-only (.client.vue)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 04:18:54 +01:00
parent dbe2ec4cb8
commit 4a7fba5999
9 changed files with 479 additions and 4 deletions

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import Konva from 'konva'
import { mapZones, mapConnections, type MapZone } from '~/data/mapZones'
const props = defineProps<{
currentSection?: string
}>()
const { locale, t } = useI18n()
const router = useRouter()
const localePath = useLocalePath()
const progressionStore = useProgressionStore()
const reducedMotion = useReducedMotion()
const containerRef = ref<HTMLDivElement | null>(null)
const stageRef = ref<Konva.Stage | null>(null)
const animationRef = ref<Konva.Animation | null>(null)
const CANVAS_WIDTH = 800
const CANVAS_HEIGHT = 400
const hoveredZone = ref<MapZone | null>(null)
const tooltipPosition = ref({ x: 0, y: 0 })
const focusedZoneIndex = ref(-1)
onMounted(() => {
if (containerRef.value) {
initCanvas()
}
})
onUnmounted(() => {
if (animationRef.value) {
animationRef.value.stop()
}
if (stageRef.value) {
stageRef.value.destroy()
}
})
function initCanvas() {
if (!containerRef.value) return
const stage = new Konva.Stage({
container: containerRef.value,
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
})
stageRef.value = stage
const backgroundLayer = new Konva.Layer()
drawBackground(backgroundLayer)
stage.add(backgroundLayer)
const zonesLayer = new Konva.Layer()
drawZones(zonesLayer)
stage.add(zonesLayer)
if (props.currentSection && !reducedMotion.value) {
const markerLayer = new Konva.Layer()
drawPositionMarker(markerLayer)
stage.add(markerLayer)
}
}
function drawBackground(layer: Konva.Layer) {
const background = new Konva.Rect({
x: 0,
y: 0,
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
fill: '#0f172a',
})
layer.add(background)
mapConnections.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',
strokeWidth: 2,
dash: [10, 5],
opacity: 0.5,
})
layer.add(line)
}
})
}
function drawZones(layer: Konva.Layer) {
mapZones.forEach((zone) => {
const isVisited =
zone.id !== 'contact' && progressionStore.visitedSections.includes(zone.id)
const isLocked = zone.id === 'contact' && !progressionStore.contactUnlocked
const group = new Konva.Group({
x: zone.position.x,
y: zone.position.y,
})
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)
const icon = new Konva.Text({
text: isLocked ? '🔒' : zone.emoji,
fontSize: 32,
x: -16,
y: -16,
})
group.add(icon)
if (isVisited && zone.id !== 'contact') {
const check = new Konva.Text({
text: '✓',
fontSize: 24,
fill: '#22c55e',
x: zone.size - 25,
y: -zone.size - 5,
})
group.add(check)
}
group.on('mouseenter', () => {
document.body.style.cursor = 'pointer'
hoveredZone.value = zone
tooltipPosition.value = {
x: zone.position.x,
y: zone.position.y - zone.size - 20,
}
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) {
const currentZone = mapZones.find((z) => z.id === props.currentSection)
if (!currentZone) return
const marker = new Konva.Circle({
x: currentZone.position.x,
y: currentZone.position.y,
radius: 10,
fill: '#fa784f',
opacity: 1,
})
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)
animationRef.value = anim
anim.start()
layer.add(marker)
}
function handleZoneClick(zone: MapZone) {
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
return
}
const route = locale.value === 'fr' ? zone.route.fr : zone.route.en
router.push(route)
}
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() {
const zone = mapZones[focusedZoneIndex.value]
hoveredZone.value = zone
tooltipPosition.value = {
x: zone.position.x,
y: zone.position.y - zone.size - 20,
}
}
function getZoneStatus(zone: MapZone): string {
if (zone.id === 'contact' && !progressionStore.contactUnlocked) {
return t('map.locked')
}
if (zone.id !== 'contact' && progressionStore.visitedSections.includes(zone.id)) {
return t('map.visited')
}
return t('map.clickToExplore')
}
</script>
<template>
<div
class="interactive-map-container relative"
tabindex="0"
role="application"
:aria-label="t('map.ariaLabel')"
@keydown="handleKeydown"
>
<div
ref="containerRef"
class="konva-container rounded-xl overflow-hidden shadow-2xl border border-sky-dark-100"
/>
<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">
{{ getZoneStatus(hoveredZone) }}
</p>
<div
class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-sky-dark-50 border-r border-b border-sky-dark-100 transform rotate-45"
/>
</div>
</Transition>
<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.5">
<span class="w-3 h-3 rounded-full bg-sky-accent opacity-60" />
<span class="text-sky-text-muted">{{ t('map.legend.notVisited') }}</span>
</div>
<div class="flex items-center gap-1.5">
<span
class="w-3 h-3 rounded-full bg-sky-accent shadow-lg"
style="box-shadow: 0 0 10px var(--sky-accent)"
/>
<span class="text-sky-text-muted">{{ t('map.legend.visited') }}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-gray-500" />
<span class="text-sky-text-muted">{{ t('map.legend.locked') }}</span>
</div>
</div>
</div>
<p class="sr-only">
{{ t('map.instructions') }}
</p>
</div>
</template>
<style scoped>
.interactive-map-container {
width: 800px;
height: 400px;
}
.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;
}
@media (prefers-reduced-motion: reduce) {
.fade-enter-active,
.fade-leave-active {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,71 @@
export interface MapZone {
id: 'projets' | 'competences' | 'temoignages' | 'parcours' | 'contact'
label: {
fr: string
en: string
}
route: {
fr: string
en: string
}
position: { x: number; y: number }
color: string
emoji: string
size: number
}
export const mapZones: MapZone[] = [
{
id: 'projets',
label: { fr: 'Projets', en: 'Projects' },
route: { fr: '/projets', en: '/en/projects' },
position: { x: 150, y: 180 },
color: '#3b82f6',
emoji: '💻',
size: 70,
},
{
id: 'competences',
label: { fr: 'Compétences', en: 'Skills' },
route: { fr: '/competences', en: '/en/skills' },
position: { x: 350, y: 100 },
color: '#10b981',
emoji: '⚡',
size: 70,
},
{
id: 'temoignages',
label: { fr: 'Témoignages', en: 'Testimonials' },
route: { fr: '/temoignages', en: '/en/testimonials' },
position: { x: 300, y: 280 },
color: '#f59e0b',
emoji: '💬',
size: 70,
},
{
id: 'parcours',
label: { fr: 'Parcours', en: 'Journey' },
route: { fr: '/parcours', en: '/en/journey' },
position: { x: 520, y: 220 },
color: '#8b5cf6',
emoji: '📍',
size: 70,
},
{
id: 'contact',
label: { fr: 'Contact', en: 'Contact' },
route: { fr: '/contact', en: '/en/contact' },
position: { x: 650, y: 150 },
color: '#fa784f',
emoji: '📧',
size: 70,
},
]
export const mapConnections = [
['projets', 'competences'],
['competences', 'temoignages'],
['temoignages', 'parcours'],
['parcours', 'contact'],
['projets', 'temoignages'],
]