- YAML carte-o-source : label central -> 'Une medecine du corps social pour
ecrire un nouveau contrat social' (phrase pleine 3 lignes)
- YAML : projet TMIP gagne lien_central:true (edge explicite centre <-> projet)
- build-carte-o.js : addEdge accepte opts.central=true pour tagger les edges
rattachees au noeud central (permet tuning force-link cote Vue)
- carte-o.json regenere : 17 nodes, 20 edges (vs 19 V1.2-O), tous les edges
central->thematiques + central->tmip portent flag central:true
- CarteO.vue : noeud central rendu en RECT 300x64 fill encre (vs cercle r30),
label blanc multi-tspan 3 lignes 13px font-weight 500 line-height 1.35
- CarteO.vue : splitCentralLabel reecrit pour wrap intelligent (3 lignes
~30 chars), preserve compat ' + ' (V1.2)
- CarteO.vue : force tuning V1.3 -> alphaDecay 0.025, velocityDecay 0.4,
forceCollide +12 (CENTRAL_COLLIDE_RADIUS=160 pour le rect), forceX/Y
strength 0.05 rappel cadre, link distance/strength differencies
(central->projet = 90/0.6, central->essai = 200/0.3)
- CarteO.vue : hover handler selector etendu rect|circle
- CarteOWrapper.vue : CarteEdge gagne champ central?:boolean
- ColCentre.astro : tabs Chatbot retires (ChatbotV2 import retire aussi),
remplaces par header bandeau 'Sommaire editorial d'architecture
d'ecologie politique' (gauche, monospace 12px) + legende 3 symboles
(publie ● / a venir ○ / projet 🟠) en droite
Build SSR : 5 pages prerender, 0 warning, 4.35s.
559 lines
18 KiB
Vue
559 lines
18 KiB
Vue
<script setup lang="ts">
|
||
// Carte O - mindmap force-directed D3 (V1 zoom + pan + drag + click).
|
||
// Pattern adapté de nav-carte/components/codev/CodevGraph.vue (sans coupling Nuxt).
|
||
// Drill-down V1 = zoom + pan seul. V2 récursif backlog (cf MP-PAGE-CERVEAU.md).
|
||
|
||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||
import * as d3 from 'd3'
|
||
|
||
interface CarteNode {
|
||
id: string
|
||
label: string
|
||
family: string
|
||
niveau?: number
|
||
nature?: 'essai' | 'projet'
|
||
statut?: 'gestation' | 'edite'
|
||
resume?: string | null
|
||
radius?: number
|
||
intention?: string
|
||
slug?: string
|
||
theme?: string
|
||
path?: string
|
||
domain?: string // V1.2-O : domaine plateforme source (logo Brandfetch CDN)
|
||
}
|
||
|
||
// V1.2-O : logos plateforme via Brandfetch CDN (visible zoom > 1.5x seulement)
|
||
const BRANDFETCH_CLIENT_ID = '4ae58bd85c8140eab0cee72f40656120'
|
||
const LOGO_ZOOM_THRESHOLD = 1.5
|
||
|
||
// V1.3-D : dimensions rectangle central (phrase manifeste 3 lignes)
|
||
const CENTRAL_W = 300
|
||
const CENTRAL_H = 64
|
||
const CENTRAL_COLLIDE_RADIUS = 160 // demi-largeur + marge
|
||
const logoUrl = (domain: string) =>
|
||
`https://cdn.brandfetch.io/${domain}/w/64/h/64?c=${BRANDFETCH_CLIENT_ID}`
|
||
|
||
interface CarteEdge {
|
||
source: string | CarteNode
|
||
target: string | CarteNode
|
||
central?: boolean // V1.3-D : link au noeud central (court/fort)
|
||
}
|
||
|
||
const props = withDefaults(defineProps<{
|
||
nodes: CarteNode[]
|
||
edges: CarteEdge[]
|
||
familyColors?: Record<string, string>
|
||
}>(), {
|
||
familyColors: () => ({
|
||
penseur: '#3b82f6',
|
||
concept: '#10b981',
|
||
methode: '#f59e0b',
|
||
collectif: '#ef4444',
|
||
ressource: '#8b5cf6',
|
||
}),
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
'node-click': [payload: { node: CarteNode; x: number; y: number }]
|
||
}>()
|
||
|
||
// Refs
|
||
const container = ref<HTMLDivElement | null>(null)
|
||
const svgEl = ref<SVGSVGElement | null>(null)
|
||
const width = ref(800)
|
||
const height = ref(600)
|
||
|
||
// State interne
|
||
type SimNode = d3.SimulationNodeDatum & CarteNode
|
||
type SimLink = d3.SimulationLinkDatum<SimNode>
|
||
|
||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
||
let gZoom: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null
|
||
|
||
const isMobile = computed(() => width.value < 600)
|
||
const nodeRadius = computed(() => isMobile.value ? 10 : 14)
|
||
|
||
// V1.2-N palette : encre / papier / ocre
|
||
// - central niveau 0 : fill encre #0F172A, label blanc inscrit dans le cercle
|
||
// - essais (niveaux 1-2) : fill papier #FFFFFF, stroke encre, label encre a droite
|
||
// - projets : fill ocre #B45309, label blanc inscrit dans le cercle
|
||
// - statut "edite" : stroke epaissi (l'epaisseur conserve l'info, pas la couleur)
|
||
function colorFor(d: SimNode): string {
|
||
if (d.nature === 'projet') return '#B45309'
|
||
if (d.niveau === 0) return '#0F172A'
|
||
return '#FFFFFF'
|
||
}
|
||
|
||
function getRadius(d: SimNode): number {
|
||
if (d.radius != null) return d.radius
|
||
if (d.niveau === 0) return 30
|
||
if (d.nature === 'projet') return 18
|
||
return nodeRadius.value
|
||
}
|
||
|
||
function strokeFor(d: SimNode): string {
|
||
if (d.nature === 'projet') return '#B45309'
|
||
if (d.niveau === 0) return '#0F172A'
|
||
return '#0F172A'
|
||
}
|
||
|
||
function strokeWidthFor(d: SimNode): number {
|
||
return d.statut === 'edite' ? 2.5 : 1.5
|
||
}
|
||
|
||
function labelWeightFor(d: SimNode): string {
|
||
return d.statut === 'edite' ? 'bold' : 'normal'
|
||
}
|
||
|
||
// Label inscrit dans le cercle (fond fonce) pour central + projets
|
||
function labelInsideFor(d: SimNode): boolean {
|
||
return d.niveau === 0 || d.nature === 'projet'
|
||
}
|
||
|
||
function labelColorFor(d: SimNode): string {
|
||
if (labelInsideFor(d)) return '#FFFFFF'
|
||
return d.statut === 'edite' ? '#0F172A' : '#475569'
|
||
}
|
||
|
||
function truncate(str: string, max: number): string {
|
||
if (!str) return ''
|
||
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
||
}
|
||
|
||
// V1.3-D : Wrap intelligent du label central sur 3 lignes (rectangle 300x64).
|
||
// Phrase cible : "Une medecine du corps social pour ecrire un nouveau contrat social"
|
||
// Heuristique : decoupe en mots, repartit sur ~3 lignes de longueur equilibree.
|
||
function splitCentralLabel(label: string, maxLines = 3, maxCharsPerLine = 30): string[] {
|
||
if (!label) return ['']
|
||
// Fallback : pivot legacy " + " (V1.2 backward compat)
|
||
if (label.includes(' + ')) {
|
||
const parts = label.split(' + ')
|
||
return [parts[0].trim(), '+', parts.slice(1).join(' + ').trim()]
|
||
}
|
||
const words = label.split(/\s+/).filter(Boolean)
|
||
if (words.length <= 1) return [label]
|
||
|
||
// Repartit en lignes en respectant maxCharsPerLine, max maxLines lignes
|
||
const lines: string[] = []
|
||
let current = ''
|
||
for (const w of words) {
|
||
const next = current ? `${current} ${w}` : w
|
||
if (next.length > maxCharsPerLine && current && lines.length < maxLines - 1) {
|
||
lines.push(current)
|
||
current = w
|
||
} else {
|
||
current = next
|
||
}
|
||
}
|
||
if (current) lines.push(current)
|
||
// Si plus de lignes que prevu (mots tres longs), concatene les dernieres
|
||
while (lines.length > maxLines) {
|
||
const last = lines.pop()!
|
||
lines[lines.length - 1] = `${lines[lines.length - 1]} ${last}`
|
||
}
|
||
return lines
|
||
}
|
||
|
||
function buildSimNodes(): SimNode[] {
|
||
return props.nodes.map(n => ({ ...n }))
|
||
}
|
||
|
||
function buildSimLinks(simNodes: SimNode[]): SimLink[] {
|
||
const byId = new Map(simNodes.map(n => [n.id, n]))
|
||
return props.edges
|
||
.map(e => {
|
||
const s = typeof e.source === 'string' ? byId.get(e.source) : byId.get(e.source.id)
|
||
const t = typeof e.target === 'string' ? byId.get(e.target) : byId.get(e.target.id)
|
||
if (!s || !t) return null
|
||
return { source: s, target: t } as SimLink
|
||
})
|
||
.filter((x): x is SimLink => x !== null)
|
||
}
|
||
|
||
function makeDrag(sim: d3.Simulation<SimNode, SimLink>) {
|
||
return d3.drag<SVGGElement, SimNode>()
|
||
.on('start', (event, d) => {
|
||
if (!event.active) sim.alphaTarget(0.3).restart()
|
||
d.fx = d.x
|
||
d.fy = d.y
|
||
})
|
||
.on('drag', (event, d) => {
|
||
d.fx = event.x
|
||
d.fy = event.y
|
||
})
|
||
.on('end', (event, d) => {
|
||
if (!event.active) sim.alphaTarget(0)
|
||
d.fx = null
|
||
d.fy = null
|
||
})
|
||
}
|
||
|
||
function initSvg() {
|
||
if (!svgEl.value) return
|
||
|
||
svgRoot = d3.select(svgEl.value)
|
||
.attr('width', width.value)
|
||
.attr('height', height.value)
|
||
.attr('role', 'img')
|
||
.attr('aria-label', 'Carte O — mindmap de la pensée AEP')
|
||
|
||
svgRoot.selectAll('*').remove()
|
||
|
||
// Wrapper group for zoom/pan transform.
|
||
gZoom = svgRoot.append('g').attr('class', 'zoom-layer')
|
||
gLinks = gZoom.append('g').attr('class', 'links')
|
||
gNodes = gZoom.append('g').attr('class', 'nodes')
|
||
|
||
// Zoom + pan behavior.
|
||
zoomBehavior = d3.zoom<SVGSVGElement, unknown>()
|
||
.scaleExtent([0.3, 4])
|
||
.on('zoom', (event) => {
|
||
gZoom!.attr('transform', event.transform.toString())
|
||
// V1.2-O : toggle visibilite logos plateforme selon le niveau de zoom
|
||
// (evite la surcharge visuelle au niveau d'ensemble, montre detail au drill-down)
|
||
const scale = event.transform.k
|
||
gZoom!.selectAll('.logo-overlay')
|
||
.style('opacity', scale > LOGO_ZOOM_THRESHOLD ? 1 : 0)
|
||
})
|
||
|
||
svgRoot.call(zoomBehavior as any)
|
||
}
|
||
|
||
function render() {
|
||
if (!svgEl.value || props.nodes.length === 0) return
|
||
|
||
initSvg()
|
||
|
||
const simNodes = buildSimNodes()
|
||
const simLinks = buildSimLinks(simNodes)
|
||
|
||
const fontSize = isMobile.value ? 9 : 11
|
||
|
||
// Liens — gris doux papier
|
||
gLinks!
|
||
.selectAll<SVGLineElement, SimLink>('line')
|
||
.data(simLinks)
|
||
.join('line')
|
||
.attr('stroke', '#94A3B8')
|
||
.attr('stroke-opacity', 0.4)
|
||
.attr('stroke-width', 1)
|
||
|
||
// Noeuds = groupe <g> par node
|
||
const nodeGroups = gNodes!
|
||
.selectAll<SVGGElement, SimNode>('g.node')
|
||
.data(simNodes, d => d.id)
|
||
.join('g')
|
||
.attr('class', 'node')
|
||
.style('cursor', 'pointer')
|
||
.on('click', (_event, d) => emit('node-click', { node: d as CarteNode, x: (d as SimNode).x || 0, y: (d as SimNode).y || 0 }))
|
||
.on('mouseover', function (_event, d) {
|
||
// V1.3-D : selecteur etendu rect|circle (rect pour central, circle pour autres)
|
||
d3.select(this).select('rect, circle')
|
||
.transition().duration(120)
|
||
.attr('stroke-width', strokeWidthFor(d) + 1.5)
|
||
})
|
||
.on('mouseout', function (_event, d) {
|
||
d3.select(this).select('rect, circle')
|
||
.transition().duration(120)
|
||
.attr('stroke-width', strokeWidthFor(d))
|
||
})
|
||
|
||
// V1.3-D : nœud central = rectangle 300x64 (phrase 3 lignes), autres = cercle
|
||
const isCentral = (d: SimNode) => d.niveau === 0
|
||
|
||
nodeGroups.filter(isCentral)
|
||
.append('rect')
|
||
.attr('x', -CENTRAL_W / 2)
|
||
.attr('y', -CENTRAL_H / 2)
|
||
.attr('width', CENTRAL_W)
|
||
.attr('height', CENTRAL_H)
|
||
.attr('rx', 6)
|
||
.attr('fill', d => colorFor(d))
|
||
.attr('stroke', d => strokeFor(d))
|
||
.attr('stroke-width', d => strokeWidthFor(d))
|
||
|
||
nodeGroups.filter(d => !isCentral(d))
|
||
.append('circle')
|
||
.attr('r', d => getRadius(d))
|
||
.attr('fill', d => colorFor(d))
|
||
.attr('stroke', d => strokeFor(d))
|
||
.attr('stroke-width', d => strokeWidthFor(d))
|
||
|
||
// Label : inscrit dans le cercle pour central + projets, a droite sinon
|
||
// Pour le noeud central (label long), on passe en multi-lignes
|
||
nodeGroups.each(function (d) {
|
||
const g = d3.select(this)
|
||
const inside = labelInsideFor(d)
|
||
|
||
if (inside && d.niveau === 0) {
|
||
// V1.3-D : noeud central = rectangle, label 3 lignes 13px line-height 1.35
|
||
const fs = isMobile.value ? 11 : 13
|
||
const lineHeight = Math.round(fs * 1.35)
|
||
const parts = splitCentralLabel(d.label, 3, isMobile.value ? 22 : 30)
|
||
const startY = -((parts.length - 1) * lineHeight) / 2
|
||
parts.forEach((line, i) => {
|
||
g.append('text')
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'central')
|
||
.attr('x', 0)
|
||
.attr('y', startY + i * lineHeight)
|
||
.attr('font-size', fs)
|
||
.attr('font-family', 'system-ui, sans-serif')
|
||
.attr('font-weight', '500')
|
||
.attr('fill', '#FFFFFF')
|
||
.attr('pointer-events', 'none')
|
||
.text(line)
|
||
})
|
||
} else if (inside) {
|
||
// Projets : label centre dans le cercle (court)
|
||
g.append('text')
|
||
.attr('text-anchor', 'middle')
|
||
.attr('dominant-baseline', 'central')
|
||
.attr('x', 0)
|
||
.attr('y', 0)
|
||
.attr('font-size', fontSize)
|
||
.attr('font-family', 'system-ui, sans-serif')
|
||
.attr('font-weight', labelWeightFor(d))
|
||
.attr('fill', '#FFFFFF')
|
||
.attr('pointer-events', 'none')
|
||
.text(truncate(d.label, 6))
|
||
} else {
|
||
// Essais : label a droite du cercle
|
||
g.append('text')
|
||
.attr('text-anchor', 'start')
|
||
.attr('dominant-baseline', 'central')
|
||
.attr('dx', getRadius(d) + 4)
|
||
.attr('font-size', fontSize)
|
||
.attr('font-family', 'system-ui, sans-serif')
|
||
.attr('font-weight', labelWeightFor(d))
|
||
.attr('fill', labelColorFor(d))
|
||
.attr('pointer-events', 'none')
|
||
.text(truncate(d.label, isMobile.value ? 18 : 30))
|
||
}
|
||
})
|
||
|
||
// V1.2-O : Logo plateforme (visible zoom > 1.5x seulement)
|
||
// Cercle blanc 14px en bas-droite du node (offset +60% x/+60% y du radius)
|
||
// Image clip-path circle pour avatar style. Hidden par defaut (opacity 0).
|
||
const nodeGroupsWithLogo = nodeGroups.filter(d => !!d.domain)
|
||
|
||
const logoOverlay = nodeGroupsWithLogo.append('g')
|
||
.attr('class', 'logo-overlay')
|
||
.style('opacity', 0)
|
||
.attr('pointer-events', 'none')
|
||
|
||
logoOverlay.append('circle')
|
||
.attr('cx', d => getRadius(d) * 0.6)
|
||
.attr('cy', d => getRadius(d) * 0.6)
|
||
.attr('r', 9)
|
||
.attr('fill', '#FFFFFF')
|
||
.attr('stroke', '#0F172A')
|
||
.attr('stroke-width', 0.5)
|
||
|
||
logoOverlay.append('image')
|
||
.attr('href', d => logoUrl(d.domain!))
|
||
.attr('x', d => getRadius(d) * 0.6 - 7)
|
||
.attr('y', d => getRadius(d) * 0.6 - 7)
|
||
.attr('width', 14)
|
||
.attr('height', 14)
|
||
.attr('clip-path', d => `circle(7px at ${getRadius(d) * 0.6}px ${getRadius(d) * 0.6}px)`)
|
||
|
||
// Tooltip <title>
|
||
nodeGroups.append('title')
|
||
.text(d => `${d.label}\n[${d.family}]\n${truncate(d.resume || d.intention || '', 200)}`)
|
||
|
||
// V1.3-D : Simulation force-directed fit-cadre + animation continue subtile
|
||
// - link distance/strength differencies : TMIP (central) court/fort, autres essais souples
|
||
// - collide +12 zero overlap labels
|
||
// - forceX/Y faibles rappel cadre
|
||
// - alphaDecay 0.025 (anime sans vertige)
|
||
simulation = d3.forceSimulation<SimNode, SimLink>(simNodes)
|
||
.force('link', d3.forceLink<SimNode, SimLink>(simLinks)
|
||
.id(d => d.id)
|
||
.distance(l => {
|
||
const link = l as SimLink & { central?: boolean }
|
||
// Lien central -> TMIP (projet) = court (90), autres centraux (essais) = standard (200)
|
||
if (link.central) {
|
||
const tgt = link.target as SimNode
|
||
const src = link.source as SimNode
|
||
const other = (tgt.niveau === 0 ? src : tgt) as SimNode
|
||
if (other.nature === 'projet') return 90
|
||
return 200
|
||
}
|
||
return 180
|
||
})
|
||
.strength(l => {
|
||
const link = l as SimLink & { central?: boolean }
|
||
if (link.central) {
|
||
const tgt = link.target as SimNode
|
||
const src = link.source as SimNode
|
||
const other = (tgt.niveau === 0 ? src : tgt) as SimNode
|
||
if (other.nature === 'projet') return 0.6
|
||
return 0.3
|
||
}
|
||
return 0.3
|
||
}))
|
||
.force('charge', d3.forceManyBody<SimNode>().strength(d => {
|
||
if (d.niveau === 0) return -1200
|
||
if (d.niveau === 1) return -400
|
||
if (d.niveau === 2) return -180
|
||
return -220
|
||
}))
|
||
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||
.force('x', d3.forceX<SimNode>(width.value / 2).strength(0.05))
|
||
.force('y', d3.forceY<SimNode>(height.value / 2).strength(0.05))
|
||
.force('collide', d3.forceCollide<SimNode>().radius(d => {
|
||
// V1.3-D : central traite comme zone large (rect 300x64 -> rayon equivalent 160)
|
||
if (d.niveau === 0) return CENTRAL_COLLIDE_RADIUS
|
||
return getRadius(d) + 12
|
||
}))
|
||
.alphaDecay(0.025)
|
||
.velocityDecay(0.4)
|
||
.alphaMin(0.001)
|
||
.on('tick', tick)
|
||
|
||
// Bind drag once simulation exists.
|
||
nodeGroups.call(makeDrag(simulation) as any)
|
||
}
|
||
|
||
function tick() {
|
||
if (!gLinks || !gNodes) return
|
||
|
||
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
||
.attr('x1', d => (d.source as SimNode).x ?? 0)
|
||
.attr('y1', d => (d.source as SimNode).y ?? 0)
|
||
.attr('x2', d => (d.target as SimNode).x ?? 0)
|
||
.attr('y2', d => (d.target as SimNode).y ?? 0)
|
||
|
||
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
||
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
||
}
|
||
|
||
// Public methods exposed (zoom controls).
|
||
function zoomIn() {
|
||
if (!svgRoot || !zoomBehavior) return
|
||
svgRoot.transition().duration(250).call(zoomBehavior.scaleBy as any, 1.3)
|
||
}
|
||
function zoomOut() {
|
||
if (!svgRoot || !zoomBehavior) return
|
||
svgRoot.transition().duration(250).call(zoomBehavior.scaleBy as any, 0.77)
|
||
}
|
||
function zoomReset() {
|
||
if (!svgRoot || !zoomBehavior) return
|
||
svgRoot.transition().duration(350).call(zoomBehavior.transform as any, d3.zoomIdentity)
|
||
}
|
||
defineExpose({ zoomIn, zoomOut, zoomReset })
|
||
|
||
// Re-render on data changes.
|
||
watch(() => [props.nodes, props.edges], () => {
|
||
if (simulation) {
|
||
simulation.stop()
|
||
simulation = null
|
||
}
|
||
render()
|
||
}, { deep: true })
|
||
|
||
// ResizeObserver
|
||
let ro: ResizeObserver | null = null
|
||
|
||
onMounted(() => {
|
||
if (!container.value) return
|
||
width.value = container.value.clientWidth || 800
|
||
height.value = container.value.clientHeight || 600
|
||
|
||
render()
|
||
|
||
ro = new ResizeObserver(() => {
|
||
if (!container.value) return
|
||
width.value = container.value.clientWidth || 800
|
||
height.value = container.value.clientHeight || 600
|
||
if (svgRoot) {
|
||
svgRoot.attr('width', width.value).attr('height', height.value)
|
||
}
|
||
if (simulation) {
|
||
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||
simulation.force('x', d3.forceX<SimNode>(width.value / 2).strength(0.05))
|
||
simulation.force('y', d3.forceY<SimNode>(height.value / 2).strength(0.05))
|
||
simulation.alpha(0.3).restart()
|
||
}
|
||
})
|
||
ro.observe(container.value)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (simulation) simulation.stop()
|
||
if (ro) ro.disconnect()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="container" class="carte-o-wrap">
|
||
<svg ref="svgEl" class="carte-o-svg" />
|
||
|
||
<!-- Zoom controls overlay -->
|
||
<div class="zoom-controls">
|
||
<button type="button" @click="zoomIn" aria-label="Zoom avant">+</button>
|
||
<button type="button" @click="zoomReset" aria-label="Réinitialiser le zoom">⟲</button>
|
||
<button type="button" @click="zoomOut" aria-label="Zoom arrière">−</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.carte-o-wrap {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
background: #FAFAF7;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.carte-o-svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: grab;
|
||
}
|
||
|
||
.carte-o-svg:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
top: 0.5rem;
|
||
right: 0.5rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||
border-radius: 8px;
|
||
padding: 4px;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.zoom-controls button {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.zoom-controls button:hover {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
}
|
||
</style>
|