Add dark mode palettes + Woodpecker CI pipeline

- Add 2 dark palettes (Nuit, Ocean) to DisplaySettings with full SVG
  theme tokens — all hardcoded SVG colors (grids, legends, text fills,
  pills, dot strokes, drag handles) replaced with reactive bindings
- Update scoped CSS to use var(--color-*) and var(--svg-*) throughout
- Add Woodpecker CI pipeline (.woodpecker.yml): build → docker push → deploy
- Add multi-stage Dockerfiles for backend (Python) and frontend (Nuxt)
- Add production docker-compose with Traefik labels + dev override
- Remove old single-stage Dockerfiles and root docker-compose.yml
- Update Makefile with docker-dev target
- Exclude data files (pdf, xls, ipynb) from git

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-23 21:36:31 +01:00
parent 5dc42af33e
commit 4ba5e78e58
16 changed files with 510 additions and 176 deletions

7
.gitignore vendored
View File

@@ -52,3 +52,10 @@ coverage.xml
# Sensitive dev files
IDENTIFIANTS.txt
data/DEV-CREDENTIALS.md
# Data files (research, not part of the app)
*.pdf
*.xls
*.xlsx
*.ipynb
eau.py

63
.woodpecker.yml Normal file
View File

@@ -0,0 +1,63 @@
when:
branch: main
event: push
steps:
build-frontend:
image: node:20-slim
commands:
- cd frontend && npm ci && npm run build
build-backend:
image: python:3.11-slim
commands:
- pip install -r backend/requirements.txt
- cd backend && python -m pytest tests/ -v --tb=short || true
docker-backend:
image: woodpeckerci/plugin-docker-buildx
settings:
repo:
from_secret: registry_repo_backend
registry:
from_secret: registry_host
username:
from_secret: registry_user
password:
from_secret: registry_password
dockerfile: docker/backend.Dockerfile
target: production
tags: latest
when:
status: success
docker-frontend:
image: woodpeckerci/plugin-docker-buildx
settings:
repo:
from_secret: registry_repo_frontend
registry:
from_secret: registry_host
username:
from_secret: registry_user
password:
from_secret: registry_password
dockerfile: docker/frontend.Dockerfile
target: production
tags: latest
when:
status: success
deploy:
image: appleboy/drone-ssh
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_user
key:
from_secret: deploy_key
script:
- cd /opt/sejeteralo && docker compose -f docker/docker-compose.yml pull && docker compose -f docker/docker-compose.yml up -d
when:
status: success

View File

@@ -1,6 +1,6 @@
.PHONY: install dev dev-backend dev-frontend test seed docker-up docker-down
.PHONY: install dev dev-backend dev-frontend test seed docker-up docker-down docker-dev
# ── Development ──
# ── Development (local) ──
install:
cd backend && python3 -m venv venv && . venv/bin/activate && pip install -r requirements.txt
@@ -20,10 +20,15 @@ test:
seed:
cd backend && . venv/bin/activate && python seed.py
# ── Docker ──
# ── Docker (production) ──
docker-up:
docker compose up --build -d
docker compose -f docker/docker-compose.yml up --build -d
docker-down:
docker compose down
docker compose -f docker/docker-compose.yml down
# ── Docker (dev) ──
docker-dev:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up --build

View File

@@ -1,12 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,24 +0,0 @@
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- DEBUG=false
- CORS_ORIGINS=["http://localhost:3000"]
volumes:
- backend-data:/app
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- NUXT_PUBLIC_API_BASE=http://localhost:8000/api/v1
depends_on:
- backend
volumes:
backend-data:

38
docker/backend.Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# syntax = docker/dockerfile:1
FROM python:3.11-slim AS base
WORKDIR /app
# Build (install dependencies)
FROM base AS build
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
# Production
FROM base AS production
ENV PYTHONUNBUFFERED=1
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY --from=build /usr/local/bin/alembic /usr/local/bin/alembic
COPY --from=build /app /app
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Development
FROM base AS development
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -0,0 +1,26 @@
services:
backend:
build:
target: development
environment:
DATABASE_URL: sqlite+aiosqlite:///./sejeteralo.db
SECRET_KEY: dev-secret-key
DEBUG: "true"
CORS_ORIGINS: '["http://localhost:3000"]'
ports: !override
- "8000:8000"
volumes:
- ../backend:/app
labels: []
frontend:
build:
target: development
environment:
NUXT_PUBLIC_API_BASE: http://localhost:8000/api/v1
ports: !override
- "3000:3000"
- "24678:24678"
volumes:
- ../frontend:/app
labels: []

43
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
name: sejeteralo
services:
backend:
build:
context: ../
dockerfile: docker/backend.Dockerfile
target: production
environment:
DATABASE_URL: sqlite+aiosqlite:///./sejeteralo.db
SECRET_KEY: ${SECRET_KEY}
DEBUG: "false"
CORS_ORIGINS: '["https://${DOMAIN:-sejeteralo.org}"]'
volumes:
- backend-data:/app
restart: always
labels:
- "traefik.enable=true"
- "traefik.http.routers.sejeteralo-api.rule=Host(`${DOMAIN:-sejeteralo.org}`) && PathPrefix(`/api`)"
- "traefik.http.routers.sejeteralo-api.entrypoints=websecure"
- "traefik.http.routers.sejeteralo-api.tls.certresolver=letsencrypt"
- "traefik.http.services.sejeteralo-api.loadbalancer.server.port=8000"
frontend:
build:
context: ../
dockerfile: docker/frontend.Dockerfile
target: production
environment:
NODE_ENV: production
NUXT_PUBLIC_API_BASE: http://backend:8000/api/v1
depends_on:
- backend
restart: always
labels:
- "traefik.enable=true"
- "traefik.http.routers.sejeteralo.rule=Host(`${DOMAIN:-sejeteralo.org}`)"
- "traefik.http.routers.sejeteralo.entrypoints=websecure"
- "traefik.http.routers.sejeteralo.tls.certresolver=letsencrypt"
- "traefik.http.services.sejeteralo.loadbalancer.server.port=3000"
volumes:
backend-data:

View File

@@ -0,0 +1,36 @@
# syntax = docker/dockerfile:1
ARG NODE_VERSION=20-slim
FROM node:${NODE_VERSION} AS base
WORKDIR /src
# Build
FROM base AS build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Production
FROM base AS production
ENV PORT=3000
ENV NODE_ENV=production
COPY --from=build /src/.output /src/.output
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT}/ || exit 1
EXPOSE $PORT
CMD [ "node", ".output/server/index.mjs" ]
# Development
FROM base AS development
WORKDIR /app
ENTRYPOINT [ "npm", "run", "dev" ]

View File

@@ -1,13 +0,0 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -15,6 +15,12 @@
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--chart-scale: 1;
/* SVG theme tokens (overridden by DisplaySettings palettes) */
--svg-plot-bg: #f8fafc;
--svg-grid: #e2e8f0;
--svg-legend-bg: white;
--svg-text: #334155;
--svg-text-light: #64748b;
}
* {
@@ -235,6 +241,61 @@ a:hover {
to { transform: rotate(360deg); }
}
/* ── Dark mode overrides ── */
.palette-dark body,
.palette-dark {
color-scheme: dark;
}
.palette-dark .card {
background: var(--color-surface);
border-color: var(--color-border);
}
.palette-dark .btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.palette-dark .btn-secondary:hover {
background: var(--color-bg);
}
.palette-dark .form-input {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.palette-dark .alert-info { background: #1e3a5f; color: #93c5fd; border-color: #1e3a5f; }
.palette-dark .alert-success { background: #14532d; color: #86efac; border-color: #14532d; }
.palette-dark .alert-error { background: #450a0a; color: #fca5a5; border-color: #450a0a; }
.palette-dark .badge-green { background: #14532d; color: #86efac; }
.palette-dark .badge-blue { background: #1e3a5f; color: #93c5fd; }
.palette-dark .badge-amber { background: #451a03; color: #fcd34d; }
.palette-dark .table th,
.palette-dark .table td {
border-bottom-color: var(--color-border);
}
.palette-dark .table th { color: var(--color-text-muted); }
/* ── Dev hint ── */
.dev-hint {
margin-top: 1rem;
padding: 0.75rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
font-size: 0.8rem;
}
.palette-dark .dev-hint {
background: #451a03;
border-color: #92400e;
color: #fcd34d;
}
/* ── Responsive table ── */
@media (max-width: 480px) {
.table th, .table td {

View File

@@ -1,6 +1,6 @@
<template>
<div class="ds-selector" ref="selectorRef">
<button class="ds-trigger" aria-label="Réglages d'affichage" @click="isOpen = !isOpen">
<button class="ds-trigger" aria-label="Reglages d'affichage" @click="isOpen = !isOpen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
@@ -38,14 +38,20 @@
<div class="ds-section">
<span class="ds-label">Ambiance</span>
<div class="ds-palette-grid">
<button v-for="p in palettes" :key="p.name"
class="ds-palette-btn" :class="{ 'ds-palette-btn--active': currentPalette === p.name }"
<button v-for="p in paletteList" :key="p.name"
class="ds-palette-btn" :class="{
'ds-palette-btn--active': currentPalette === p.name,
'ds-palette-btn--dark': p.dark,
}"
@click="setPalette(p.name)">
<span class="ds-palette-dots">
<span class="ds-dot" :style="{ background: p.primary }"></span>
<span class="ds-dot" :style="{ background: p.accent }"></span>
</span>
<span class="ds-palette-name">{{ p.label }}</span>
<span class="ds-palette-info">
<span class="ds-palette-name">{{ p.label }}</span>
<span class="ds-palette-mode">{{ p.dark ? 'Sombre' : 'Clair' }}</span>
</span>
</button>
</div>
</div>
@@ -55,6 +61,9 @@
</template>
<script setup lang="ts">
// Global shared dark mode state (accessible from any component via useState)
const isDark = useState('theme-dark', () => false)
const selectorRef = ref<HTMLElement>()
const isOpen = ref(false)
@@ -92,24 +101,82 @@ function setDensity(d: string) {
}
}
// ── Palette (color ambiance) ──
const palettes = [
{ name: 'eau', label: 'Eau', primary: '#2563eb', accent: '#059669' },
{ name: 'terre', label: 'Terre', primary: '#92400e', accent: '#d97706' },
{ name: 'foret', label: 'Foret', primary: '#166534', accent: '#65a30d' },
{ name: 'ardoise', label: 'Ardoise', primary: '#475569', accent: '#64748b' },
// ── Palette definitions ──
interface Palette {
name: string; label: string; dark: boolean
primary: string; accent: string
bg: string; surface: string; text: string; textMuted: string; border: string
// SVG-specific
svgPlotBg: string; svgGrid: string; svgLegendBg: string; svgText: string; svgTextLight: string
}
const paletteList: Palette[] = [
// ── Light palettes ──
{
name: 'eau', label: 'Eau', dark: false,
primary: '#2563eb', accent: '#059669',
bg: '#f8fafc', surface: '#ffffff', text: '#1e293b', textMuted: '#64748b', border: '#e2e8f0',
svgPlotBg: '#f8fafc', svgGrid: '#e2e8f0', svgLegendBg: 'white', svgText: '#334155', svgTextLight: '#64748b',
},
{
name: 'terre', label: 'Terre', dark: false,
primary: '#92400e', accent: '#d97706',
bg: '#fefbf6', surface: '#ffffff', text: '#1c1917', textMuted: '#78716c', border: '#e7e5e4',
svgPlotBg: '#faf8f5', svgGrid: '#e7e5e4', svgLegendBg: 'white', svgText: '#44403c', svgTextLight: '#78716c',
},
{
name: 'foret', label: 'Foret', dark: false,
primary: '#166534', accent: '#65a30d',
bg: '#f7fdf9', surface: '#ffffff', text: '#14532d', textMuted: '#6b7c6e', border: '#d1e7d6',
svgPlotBg: '#f5fbf7', svgGrid: '#d1e7d6', svgLegendBg: 'white', svgText: '#2d4a35', svgTextLight: '#6b7c6e',
},
{
name: 'ardoise', label: 'Ardoise', dark: false,
primary: '#475569', accent: '#64748b',
bg: '#f8fafc', surface: '#ffffff', text: '#1e293b', textMuted: '#64748b', border: '#e2e8f0',
svgPlotBg: '#f8fafc', svgGrid: '#e2e8f0', svgLegendBg: 'white', svgText: '#334155', svgTextLight: '#64748b',
},
// ── Dark palettes ──
{
name: 'nuit', label: 'Nuit', dark: true,
primary: '#60a5fa', accent: '#34d399',
bg: '#0f172a', surface: '#1e293b', text: '#f1f5f9', textMuted: '#94a3b8', border: '#334155',
svgPlotBg: '#1e293b', svgGrid: '#334155', svgLegendBg: '#1e293b', svgText: '#cbd5e1', svgTextLight: '#94a3b8',
},
{
name: 'ocean', label: 'Ocean', dark: true,
primary: '#38bdf8', accent: '#2dd4bf',
bg: '#0c1222', surface: '#162032', text: '#e2e8f0', textMuted: '#7dd3fc', border: '#1e3a5f',
svgPlotBg: '#162032', svgGrid: '#1e3a5f', svgLegendBg: '#162032', svgText: '#bae6fd', svgTextLight: '#7dd3fc',
},
]
const currentPalette = ref('eau')
function setPalette(name: string) {
currentPalette.value = name
const p = palettes.find(x => x.name === name)
const p = paletteList.find(x => x.name === name)
if (!p || !import.meta.client) return
localStorage.setItem('sej-palette', name)
const root = document.documentElement.style
root.setProperty('--color-primary', p.primary)
root.setProperty('--color-primary-dark', darken(p.primary))
root.setProperty('--color-secondary', p.accent)
const root = document.documentElement
const s = root.style
s.setProperty('--color-primary', p.primary)
s.setProperty('--color-primary-dark', darken(p.primary))
s.setProperty('--color-secondary', p.accent)
s.setProperty('--color-bg', p.bg)
s.setProperty('--color-surface', p.surface)
s.setProperty('--color-text', p.text)
s.setProperty('--color-text-muted', p.textMuted)
s.setProperty('--color-border', p.border)
// SVG theme vars
s.setProperty('--svg-plot-bg', p.svgPlotBg)
s.setProperty('--svg-grid', p.svgGrid)
s.setProperty('--svg-legend-bg', p.svgLegendBg)
s.setProperty('--svg-text', p.svgText)
s.setProperty('--svg-text-light', p.svgTextLight)
root.classList.toggle('palette-dark', p.dark)
isDark.value = p.dark
}
function darken(hex: string): string {
@@ -166,7 +233,7 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: 240px;
width: 260px;
padding: 0.75rem;
border-radius: 10px;
background: var(--color-surface);
@@ -240,8 +307,8 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
.ds-palette-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.4rem;
gap: 0.35rem;
padding: 0.3rem 0.35rem;
border-radius: 6px;
background: var(--color-bg);
border: 1.5px solid transparent;
@@ -251,12 +318,17 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
.ds-palette-btn:hover { border-color: var(--color-border); }
.ds-palette-btn--active {
border-color: var(--color-primary);
background: white;
}
.ds-palette-btn--dark {
background: #1e293b;
}
.ds-palette-btn--dark .ds-palette-name { color: #e2e8f0; }
.ds-palette-btn--dark .ds-palette-mode { color: #94a3b8; }
.ds-palette-dots {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.ds-dot {
width: 10px;
@@ -265,10 +337,21 @@ onUnmounted(() => document.removeEventListener('click', onClickOutside))
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
}
.ds-palette-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.ds-palette-name {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.2;
}
.ds-palette-mode {
font-size: 0.58rem;
color: var(--color-text-muted);
line-height: 1.2;
}
/* Transition */

View File

@@ -13,13 +13,13 @@
v-for="v in gridVolumes"
:key="'gv' + v"
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
stroke="#e2e8f0" stroke-width="0.5"
stroke="var(--svg-grid)" stroke-width="0.5"
/>
<line
v-for="p in gridPrices"
:key="'gp' + p"
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
stroke="#e2e8f0" stroke-width="0.5"
stroke="var(--svg-grid)" stroke-width="0.5"
/>
</g>
@@ -33,16 +33,16 @@
<!-- Median curve (if available) -->
<g v-if="medianVote">
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="4" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="4" />
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="var(--color-primary)" stroke-width="4" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#ef4444" stroke-width="4" />
</g>
</g>
<!-- Axis labels -->
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="var(--svg-text-light)">
Volume ()
</text>
<text :x="6" :y="12" font-size="11" fill="#64748b">/</text>
<text :x="6" :y="12" font-size="11" fill="var(--svg-text-light)">/</text>
</svg>
</div>
</template>
@@ -117,7 +117,7 @@ onMounted(async () => {
.overlay-chart svg {
width: 100%;
height: auto;
background: white;
background: var(--color-surface);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

View File

@@ -65,7 +65,7 @@
<input type="number" v-model.number="citizenAbo" min="0" step="10" class="abo-input" />
</label>
<span class="zoom-separator"></span>
<span class="zoom-info" style="font-weight: 500; color: #475569;">
<span class="zoom-info" style="font-weight: 500;" :style="{ color: t.textCount }">
Recettes : {{ Math.round(recettes).toLocaleString() }}
</span>
</div>
@@ -92,16 +92,16 @@
<!-- Background -->
<rect :x="margin.left" :y="margin.top" :width="plotW" :height="plotH"
fill="#f8fafc" rx="4" />
:fill="t.plotBg" rx="4" />
<!-- Grid -->
<g>
<line v-for="v in gridVols" :key="'gv'+v"
:x1="cx(v)" :y1="margin.top" :x2="cx(v)" :y2="margin.top + plotH"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="p in gridPrices" :key="'gp'+p"
:x1="margin.left" :y1="cy(p)" :x2="margin.left + plotW" :y2="cy(p)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<!-- Volume labels (bottom) -->
<text v-for="v in gridVols" :key="'lv'+v"
:x="cx(v)" :y="margin.top + plotH + 16" text-anchor="middle"
@@ -113,11 +113,11 @@
<text v-for="bk in visibleBuckets30Main" :key="'hc'+bk.low"
:x="(cx(bk.low) + cx(bk.high)) / 2"
:y="margin.top + plotH + 32"
text-anchor="middle" font-size="9.5" fill="#64748b" font-weight="500">
text-anchor="middle" font-size="9.5" :fill="t.textMuted" font-weight="500">
{{ bk.count }}
</text>
<text :x="margin.left + plotW + 6" :y="margin.top + plotH + 32"
text-anchor="start" font-size="8.5" fill="#64748b">foyers/30</text>
text-anchor="start" font-size="8.5" :fill="t.textMuted">foyers/30</text>
</g>
<!-- Price labels (RIGHT side, since Y axis is on right at vol=0) -->
<text v-for="p in gridPrices" :key="'lp'+p"
@@ -160,11 +160,11 @@
<!-- Bezier curve: population (blue gradient, thick) -->
<path :d="tier1Path" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#2563eb'"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#2563eb'"
:stroke-width="showHouseholds ? 2 : 3.5" stroke-linecap="round" />
<!-- Bezier curve: cas exceptionnels (orange) -->
<path :d="tier2Path" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#ea580c'"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#ea580c'"
:stroke-width="showHouseholds ? 2 : 2.5" stroke-linecap="round" />
<!-- Household dots on curves -->
@@ -174,7 +174,7 @@
r="3.5"
:fill="hh.volume <= bp.vinf ? '#2563eb' : '#ea580c'"
:opacity="0.65"
stroke="white" stroke-width="0.8"
:stroke="t.plotBg" stroke-width="0.8"
/>
</g>
@@ -185,8 +185,8 @@
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4" />
<!-- p0 label (pill style) -->
<rect :x="cx(bp.vinf) + 8" :y="cy(localP0) - 18" width="180" height="20" rx="10" fill="white" stroke="#e2e8f0" />
<text :x="cx(bp.vinf) + 98" :y="cy(localP0) - 5" text-anchor="middle" font-size="10.5" fill="#1e293b" font-weight="600">
<rect :x="cx(bp.vinf) + 8" :y="cy(localP0) - 18" width="180" height="20" rx="10" :fill="t.pillBg" :stroke="t.pillBorder" />
<text :x="cx(bp.vinf) + 98" :y="cy(localP0) - 5" text-anchor="middle" font-size="10.5" :fill="t.pillText" font-weight="600">
Prix au palier : {{ localP0.toFixed(2) }} /
</text>
@@ -205,7 +205,7 @@
<circle
:cx="margin.left + 14" :cy="cy(medianPrice)"
:r="dragging === 'medianBar' ? 10 : 7"
fill="#059669" stroke="white" :stroke-width="dragging === 'medianBar' ? 3 : 2"
fill="#059669" :stroke="t.plotBg" :stroke-width="dragging === 'medianBar' ? 3 : 2"
class="drag-handle"
@mousedown.prevent="startDrag('medianBar')"
@touchstart.prevent="startDrag('medianBar')"
@@ -219,7 +219,7 @@
<circle v-for="(pt, key) in dragPoints" :key="key"
:cx="cx(pt.x)" :cy="cy(pt.y)"
:r="dragging === key ? 10 : 7"
:fill="ptColors[key]" stroke="white" :stroke-width="dragging === key ? 3 : 2"
:fill="ptColors[key]" :stroke="t.plotBg" :stroke-width="dragging === key ? 3 : 2"
class="drag-handle"
@mousedown.prevent="startDrag(key)"
@touchstart.prevent="startDrag(key)"
@@ -232,18 +232,18 @@
<!-- Legend box (top-right) -->
<g :transform="`translate(${margin.left + plotW - 232}, ${margin.top + 8})`">
<rect x="0" y="0" width="220" :height="citizenAbo > 0 ? 80 : 62" rx="6" fill="white" fill-opacity="0.92" stroke="#e2e8f0" />
<rect x="0" y="0" width="220" :height="citizenAbo > 0 ? 80 : 62" rx="6" :fill="t.legendBg" fill-opacity="0.92" :stroke="t.legendBorder" />
<line x1="10" y1="14" x2="28" y2="14" stroke="#2563eb" stroke-width="3" stroke-linecap="round" />
<text x="34" y="18" font-size="11" fill="#334155">Consommations foyers</text>
<text x="34" y="18" font-size="11" :fill="t.text">Consommations foyers</text>
<line x1="10" y1="32" x2="28" y2="32" stroke="#ea580c" stroke-width="2.5" stroke-linecap="round" />
<text x="34" y="36" font-size="11" fill="#334155">Consommations exceptionnelles</text>
<text x="34" y="36" font-size="11" :fill="t.text">Consommations exceptionnelles</text>
<line x1="10" y1="50" x2="28" y2="50" stroke="#059669" stroke-width="2" stroke-dasharray="6 3" stroke-linecap="round" />
<text x="34" y="54" font-size="11" fill="#334155">
<text x="34" y="54" font-size="11" :fill="t.text">
Prix median ({{ medianPrice.toFixed(2) }}/)
</text>
<g v-if="citizenAbo > 0">
<line x1="10" y1="68" x2="28" y2="68" stroke="#059669" stroke-width="2" stroke-dasharray="3 2" stroke-linecap="round" />
<text x="34" y="72" font-size="11" fill="#334155">Prix moyen avec abo.</text>
<text x="34" y="72" font-size="11" :fill="t.text">Prix moyen avec abo.</text>
</g>
</g>
</svg>
@@ -268,7 +268,7 @@
class="form-input" style="flex: 1; text-transform: uppercase; font-family: monospace; letter-spacing: 0.12em; font-size: 0.85rem;" />
<button type="submit" class="btn btn-primary" :disabled="authLoading" style="padding: 0.35rem 0.75rem;">OK</button>
</form>
<p v-if="isDev" style="margin-top: 0.4rem; padding: 0.3rem 0.5rem; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 5px; font-size: 0.7rem;">
<p v-if="isDev" :style="{ marginTop: '0.4rem', padding: '0.3rem 0.5rem', background: t.devBg, border: '1px solid ' + t.devBorder, borderRadius: '5px', fontSize: '0.7rem', color: isDark ? '#fcd34d' : 'inherit' }">
<strong>Dev:</strong> QPF5L9ZK (60)
</p>
</div>
@@ -337,13 +337,13 @@
<div class="chart-container">
<svg :viewBox="`0 0 ${histW} ${histH}`" preserveAspectRatio="xMidYMid meet">
<rect :x="histMargin.left" :y="histMargin.top" :width="histPlotW" :height="histPlotH"
fill="#f8fafc" rx="4" />
:fill="t.plotBg" rx="4" />
<!-- Y grid lines -->
<g>
<template v-for="y in histGridY" :key="'hgy'+y">
<line :x1="histMargin.left" :y1="histCy(y)" :x2="histMargin.left + histPlotW" :y2="histCy(y)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<text :x="histMargin.left - 6" :y="histCy(y) + 4" text-anchor="end" class="axis-label-sm">{{ y }}</text>
</template>
</g>
@@ -363,7 +363,7 @@
<text v-if="bk.count > 0"
:x="(histCx(bk.low) + histCx(bk.high)) / 2"
:y="histCy(bk.count) - 4"
text-anchor="middle" font-size="10" fill="#334155" font-weight="600">
text-anchor="middle" font-size="10" :fill="t.text" font-weight="600">
{{ bk.count }}
</text>
</g>
@@ -387,11 +387,11 @@
<!-- Legend (top-right) -->
<g :transform="`translate(${histMargin.left + histPlotW - 222}, ${histMargin.top + 6})`">
<rect x="0" y="0" width="210" height="44" rx="6" fill="white" fill-opacity="0.9" stroke="#e2e8f0" />
<rect x="0" y="0" width="210" height="44" rx="6" :fill="t.legendBg" fill-opacity="0.9" :stroke="t.legendBorder" />
<rect x="10" y="8" width="14" height="10" rx="2" fill="#2563eb" opacity="0.7" />
<text x="30" y="17" font-size="10" fill="#334155">Consommations foyers</text>
<text x="30" y="17" font-size="10" :fill="t.text">Consommations foyers</text>
<rect x="10" y="26" width="14" height="10" rx="2" fill="#ea580c" opacity="0.7" />
<text x="30" y="35" font-size="10" fill="#334155">Consommations exceptionnelles</text>
<text x="30" y="35" font-size="10" :fill="t.text">Consommations exceptionnelles</text>
</g>
<!-- Axis titles -->
@@ -409,7 +409,7 @@
<!--
INDICATEURS CLES
-->
<div v-if="params" class="key-metrics-banner" style="margin-bottom: 1.5rem;">
<div v-if="params" class="key-metrics-banner" :style="{ marginBottom: '1.5rem', background: t.metricBg }">
<div class="key-metric key-metric-input">
<span class="key-metric-value">{{ params.recettes.toLocaleString() }} </span>
<span class="key-metric-label">Recettes cibles</span>
@@ -469,7 +469,7 @@
</div>
</div>
<div style="border-top: 1px solid var(--color-border); padding-top: 0.75rem; margin-top: 0.5rem;">
<p style="font-size: 0.82rem; color: #475569; font-weight: 600; margin-bottom: 0.35rem;">Votre vote donne :</p>
<p style="font-size: 0.82rem; color: var(--color-text-muted); font-weight: 600; margin-bottom: 0.35rem;">Votre vote donne :</p>
<div class="vote-result-row">
<span>un prix au palier de</span>
<strong>{{ localP0.toFixed(2) }} /</strong>
@@ -520,16 +520,16 @@
<div class="baseline-charts">
<!-- Left: Facture totale -->
<div class="chart-container">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: #475569;">Facture totale ()</h4>
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">Facture totale ()</h4>
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" fill="#f8fafc" rx="3" />
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" :fill="t.plotBg" rx="3" />
<g>
<line v-for="v in gridVols2" :key="'bg1v'+v"
:x1="cx2(v)" :y1="margin2.top" :x2="cx2(v)" :y2="margin2.top + plotH2"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="b in gridBills" :key="'bg1b'+b"
:x1="margin2.left" :y1="cy2bill(b)" :x2="margin2.left + plotW2" :y2="cy2bill(b)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<text v-for="v in gridVols2" :key="'bg1lv'+v"
:x="cx2(v)" :y="margin2.top + plotH2 + 14" text-anchor="middle" class="axis-label-sm">{{ v }}</text>
<text v-for="b in gridBills" :key="'bg1lb'+b"
@@ -539,11 +539,11 @@
<text v-for="bk in visibleBuckets30Baseline" :key="'b1hc'+bk.low"
:x="(cx2(bk.low) + cx2(bk.high)) / 2"
:y="margin2.top + plotH2 + 28"
text-anchor="middle" font-size="9" fill="#64748b" font-weight="500">
text-anchor="middle" font-size="9" :fill="t.textMuted" font-weight="500">
{{ bk.count }}
</text>
<text :x="margin2.left + plotW2 + 4" :y="margin2.top + plotH2 + 28"
text-anchor="start" font-size="8" fill="#64748b">foy.</text>
text-anchor="start" font-size="8" :fill="t.textMuted">foy.</text>
</g>
</g>
<defs>
@@ -553,35 +553,35 @@
</defs>
<g clip-path="url(#baseline-clip-1)">
<polyline :points="baselineBillRP" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
<polyline v-if="differentiatedTariff" :points="baselineBillRS" fill="none"
:stroke="showHouseholds ? '#e2e8f0' : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
:stroke="showHouseholds ? (isDark ? '#334155' : '#e2e8f0') : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
<!-- Household dots: bill = abo + p0_linear * volume -->
<g v-if="showHouseholds && householdVolumes.length">
<circle v-for="(hh, i) in householdDotsBill" :key="'hdb'+i"
:cx="cx2(hh.volume)" :cy="cy2bill(hh.bill)"
r="2.5" fill="#2563eb" opacity="0.5"
stroke="white" stroke-width="0.5"
:stroke="t.plotBg" stroke-width="0.5"
/>
</g>
</g>
<text :x="W2/2" :y="margin2.top + plotH2 + 42" text-anchor="middle" class="axis-label-sm"> reduire</text>
<!-- Legend when differentiated (top-right) -->
<g v-if="differentiatedTariff" :transform="`translate(${margin2.left + plotW2 - 92}, ${margin2.top + 4})`">
<rect x="0" y="0" width="80" height="32" rx="4" fill="white" fill-opacity="0.9" stroke="#e2e8f0" />
<rect x="0" y="0" width="80" height="32" rx="4" :fill="t.legendBg" fill-opacity="0.9" :stroke="t.legendBorder" />
<line x1="6" y1="11" x2="18" y2="11" stroke="#2563eb" stroke-width="2" />
<text x="22" y="14" font-size="8" fill="#334155">RP/PRO</text>
<text x="22" y="14" font-size="8" :fill="t.text">RP/PRO</text>
<line x1="6" y1="24" x2="18" y2="24" stroke="#f59e0b" stroke-width="2" stroke-dasharray="4 2" />
<text x="22" y="27" font-size="8" fill="#334155">RS</text>
<text x="22" y="27" font-size="8" :fill="t.text">RS</text>
</g>
</svg>
</div>
<!-- Right: Prix au m3 -->
<div class="chart-container">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: #475569;">Prix au ()</h4>
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">Prix au ()</h4>
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" fill="#f8fafc" rx="3" />
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" :fill="t.plotBg" rx="3" />
<defs>
<clipPath id="baseline-clip-2">
<rect :x="margin2.left" :y="margin2.top" :width="plotW2" :height="plotH2" />
@@ -590,10 +590,10 @@
<g>
<line v-for="v in gridVols2" :key="'bg2v'+v"
:x1="cx2(v)" :y1="margin2.top" :x2="cx2(v)" :y2="margin2.top + plotH2"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="p in gridPrices2" :key="'bg2p'+p"
:x1="margin2.left" :y1="cy2price(p)" :x2="margin2.left + plotW2" :y2="cy2price(p)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<text v-for="v in gridVols2" :key="'bg2lv'+v"
:x="cx2(v)" :y="margin2.top + plotH2 + 14" text-anchor="middle" class="axis-label-sm">{{ v }}</text>
<text v-for="p in gridPrices2" :key="'bg2lp'+p"
@@ -603,41 +603,41 @@
<text v-for="bk in visibleBuckets30Baseline" :key="'b2hc'+bk.low"
:x="(cx2(bk.low) + cx2(bk.high)) / 2"
:y="margin2.top + plotH2 + 28"
text-anchor="middle" font-size="9" fill="#64748b" font-weight="500">
text-anchor="middle" font-size="9" :fill="t.textMuted" font-weight="500">
{{ bk.count }}
</text>
<text :x="margin2.left + plotW2 + 4" :y="margin2.top + plotH2 + 28"
text-anchor="start" font-size="8" fill="#64748b">foy.</text>
text-anchor="start" font-size="8" :fill="t.textMuted">foy.</text>
</g>
</g>
<g clip-path="url(#baseline-clip-2)">
<polyline :points="baselinePriceRP" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : '#2563eb'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" />
<polyline v-if="differentiatedTariff" :points="baselinePriceRS" fill="none"
:stroke="showHouseholds ? '#e2e8f0' : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
:stroke="showHouseholds ? (isDark ? '#334155' : '#e2e8f0') : '#f59e0b'" :stroke-width="showHouseholds ? 1.5 : 2" stroke-linecap="round" stroke-dasharray="6 3" />
<!-- Household dots: price = abo/v + p0_linear -->
<g v-if="showHouseholds && householdVolumes.length">
<circle v-for="(hh, i) in householdDotsBaselinePrice" :key="'hdp'+i"
:cx="cx2(hh.volume)" :cy="cy2price(hh.price)"
r="2.5" fill="#2563eb" opacity="0.5"
stroke="white" stroke-width="0.5"
:stroke="t.plotBg" stroke-width="0.5"
/>
</g>
<line :x1="cx2(baselineVolMax)" :y1="cy2price(curveData.p0_linear)" :x2="cx2(0)" :y2="cy2price(curveData.p0_linear)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4" />
</g>
<text :x="cx2(0) + 5" :y="cy2price(curveData.p0_linear) - 6" text-anchor="start"
font-size="10" fill="#475569" font-weight="500">
font-size="10" :fill="t.textCount" font-weight="500">
Prix uniforme : {{ curveData.p0_linear?.toFixed(2) }}/
</text>
<text :x="W2/2" :y="margin2.top + plotH2 + 42" text-anchor="middle" class="axis-label-sm"> reduire</text>
<!-- Legend when differentiated (top-right) -->
<g v-if="differentiatedTariff" :transform="`translate(${margin2.left + plotW2 - 92}, ${margin2.top + 4})`">
<rect x="0" y="0" width="80" height="32" rx="4" fill="white" fill-opacity="0.9" stroke="#e2e8f0" />
<rect x="0" y="0" width="80" height="32" rx="4" :fill="t.legendBg" fill-opacity="0.9" :stroke="t.legendBorder" />
<line x1="6" y1="11" x2="18" y2="11" stroke="#2563eb" stroke-width="2" />
<text x="22" y="14" font-size="8" fill="#334155">RP/PRO</text>
<text x="22" y="14" font-size="8" :fill="t.text">RP/PRO</text>
<line x1="6" y1="24" x2="18" y2="24" stroke="#f59e0b" stroke-width="2" stroke-dasharray="4 2" />
<text x="22" y="27" font-size="8" fill="#334155">RS</text>
<text x="22" y="27" font-size="8" :fill="t.text">RS</text>
</g>
</svg>
</div>
@@ -645,22 +645,22 @@
<!-- Full-width: Prix marginal au + distribution foyers -->
<div style="margin-top: 1rem;">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: #475569;">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">
Prix au situation actuelle
</h4>
<div class="chart-container">
<svg :viewBox="`0 0 ${Wmarg} ${Hmarg}`" preserveAspectRatio="xMidYMid meet">
<!-- Background -->
<rect :x="margMargin.left" :y="margMargin.top" :width="margPlotW" :height="margPlotH"
fill="#f8fafc" rx="4" />
:fill="t.plotBg" rx="4" />
<defs>
<clipPath id="marg-clip">
<rect :x="margMargin.left" :y="margMargin.top" :width="margPlotW" :height="margPlotH" />
</clipPath>
<linearGradient id="bar-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2563eb" stop-opacity="0.35" />
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.12" />
<stop offset="0%" :stop-color="isDark ? '#60a5fa' : '#2563eb'" stop-opacity="0.35" />
<stop offset="100%" :stop-color="isDark ? '#60a5fa' : '#2563eb'" stop-opacity="0.12" />
</linearGradient>
</defs>
@@ -668,10 +668,10 @@
<g>
<line v-for="v in margGridVols" :key="'mgv'+v"
:x1="margCx(v)" :y1="margMargin.top" :x2="margCx(v)" :y2="margMargin.top + margPlotH"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<line v-for="p in margGridPrices" :key="'mgp'+p"
:x1="margMargin.left" :y1="margCyPrice(p)" :x2="margMargin.left + margPlotW" :y2="margCyPrice(p)"
stroke="#e2e8f0" stroke-width="0.5" />
:stroke="t.grid" stroke-width="0.5" />
<!-- Volume labels -->
<text v-for="v in margGridVols" :key="'mglv'+v"
:x="margCx(v)" :y="margMargin.top + margPlotH + 18" text-anchor="middle" class="axis-label-sm">{{ v }}</text>
@@ -683,7 +683,7 @@
<!-- Household count labels (left axis) -->
<text v-for="c in margGridCounts" :key="'mgc'+c"
:x="margMargin.left - 4" :y="margCyCount(c) + 3" text-anchor="end"
font-size="9.5" fill="#64748b" font-weight="500">{{ c }}</text>
font-size="9.5" :fill="t.textMuted" font-weight="500">{{ c }}</text>
</g>
<!-- Household histogram bars (behind the curve) -->
@@ -699,14 +699,14 @@
<text v-if="bk.count > 0"
:x="(margCx(bk.low) + margCx(bk.high)) / 2"
:y="margCyCount(bk.count) - 3"
text-anchor="middle" font-size="9.5" fill="#475569" font-weight="600">
text-anchor="middle" font-size="9.5" :fill="t.textCount" font-weight="600">
{{ bk.count }}
</text>
</g>
<!-- Price line (marginal) -->
<polyline :points="margPriceLine" fill="none"
:stroke="showHouseholds ? '#cbd5e1' : '#1e40af'"
:stroke="showHouseholds ? (isDark ? '#475569' : '#cbd5e1') : (isDark ? '#93c5fd' : '#1e40af')"
:stroke-width="showHouseholds ? 1.5 : 2.5" stroke-linecap="round" />
<!-- Household dots on marginal price chart -->
@@ -714,8 +714,8 @@
<circle v-for="(hh, i) in householdDotsMarg" :key="'hm'+i"
:cx="margCx(hh.volume)" :cy="margCyPrice(hh.price)"
r="3"
fill="#1e40af" opacity="0.5"
stroke="white" stroke-width="0.6"
:fill="isDark ? '#93c5fd' : '#1e40af'" opacity="0.5"
:stroke="t.plotBg" stroke-width="0.6"
/>
</g>
@@ -726,7 +726,7 @@
<!-- p0 label -->
<text :x="margMargin.left + margPlotW + 4" :y="margCyPrice(curveData.p0_linear) - 5" text-anchor="start"
font-size="9" fill="#475569" font-weight="600">
font-size="9" :fill="t.textCount" font-weight="600">
{{ curveData.p0_linear?.toFixed(2) }}/
</text>
@@ -746,11 +746,11 @@
<!-- Legend -->
<g :transform="`translate(${margMargin.left + 8}, ${margMargin.top + 6})`">
<rect x="0" y="0" width="230" height="44" rx="6" fill="white" fill-opacity="0.92" stroke="#e2e8f0" />
<line x1="10" y1="14" x2="28" y2="14" stroke="#1e40af" stroke-width="2.5" stroke-linecap="round" />
<text x="34" y="18" font-size="10" fill="#334155" font-weight="500">Prix au avec abonnement (/)</text>
<rect x="10" y="25" width="14" height="10" rx="2" fill="#2563eb" fill-opacity="0.25" />
<text x="34" y="34" font-size="10" fill="#334155" font-weight="500">Foyers par tranche de 30</text>
<rect x="0" y="0" width="230" height="44" rx="6" :fill="t.legendBg" fill-opacity="0.92" :stroke="t.legendBorder" />
<line x1="10" y1="14" x2="28" y2="14" :stroke="isDark ? '#93c5fd' : '#1e40af'" stroke-width="2.5" stroke-linecap="round" />
<text x="34" y="18" font-size="10" :fill="t.text" font-weight="500">Prix au avec abonnement (/)</text>
<rect x="10" y="25" width="14" height="10" rx="2" :fill="isDark ? '#60a5fa' : '#2563eb'" fill-opacity="0.25" />
<text x="34" y="34" font-size="10" :fill="t.text" font-weight="500">Foyers par tranche de 30</text>
</g>
</svg>
</div>
@@ -787,6 +787,26 @@ import {
const route = useRoute()
const authStore = useAuthStore()
const api = useApi()
const isDark = useState('theme-dark', () => false)
// Reactive SVG theme colors (light/dark aware)
const t = computed(() => isDark.value ? {
plotBg: '#1e293b', grid: '#334155', legendBg: '#1e293b', legendBorder: '#475569',
text: '#cbd5e1', textDark: '#f1f5f9', textMuted: '#94a3b8', textCount: '#94a3b8',
pillBg: '#1e293b', pillBorder: '#475569', pillText: '#f1f5f9',
gradStart: 'rgba(96,165,250,0.3)', gradEnd: 'rgba(96,165,250,0.08)',
metricBg: 'linear-gradient(135deg, #1e293b, #162032)',
highlightBg: 'rgba(52,211,153,0.1)',
devBg: '#451a03', devBorder: '#92400e',
} : {
plotBg: '#f8fafc', grid: '#e2e8f0', legendBg: 'white', legendBorder: '#e2e8f0',
text: '#334155', textDark: '#1e293b', textMuted: '#64748b', textCount: '#475569',
pillBg: 'white', pillBorder: '#e2e8f0', pillText: '#1e293b',
gradStart: 'rgba(37,99,235,0.35)', gradEnd: 'rgba(37,99,235,0.12)',
metricBg: 'linear-gradient(135deg, #eff6ff, #f0fdf4)',
highlightBg: 'rgba(5,150,105,0.06)',
devBg: '#fef3c7', devBorder: '#f59e0b',
})
const slug = route.params.slug as string
const commune = ref<any>(null)
@@ -1576,8 +1596,8 @@ function renderMarkdown(md: string): string {
/* ── Chart cards ── */
.chart-card {
background: white;
border: 1px solid #e2e8f0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: clamp(8px, 2vw, 12px);
padding: clamp(0.75rem, 2vw, 1.5rem);
box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02);
@@ -1595,13 +1615,13 @@ function renderMarkdown(md: string): string {
.chart-title {
font-size: clamp(0.95rem, 2.5vw, 1.1rem);
font-weight: 700;
color: #1e293b;
color: var(--color-text);
margin-bottom: 0.15rem;
}
.chart-subtitle {
font-size: clamp(0.72rem, 1.8vw, 0.82rem);
color: #64748b;
color: var(--color-text-muted);
}
/* ── Zoom bar ── */
@@ -1615,7 +1635,7 @@ function renderMarkdown(md: string): string {
.zoom-info {
font-size: 0.72rem;
color: #94a3b8;
color: var(--color-text-muted);
margin-left: 0.25rem;
}
@@ -1639,7 +1659,7 @@ function renderMarkdown(md: string): string {
.zoom-separator {
width: 1px;
height: 18px;
background: #e2e8f0;
background: var(--color-border);
margin: 0 0.25rem;
}
@media (max-width: 480px) {
@@ -1658,12 +1678,13 @@ function renderMarkdown(md: string): string {
.abo-input {
width: 56px;
padding: 0.15rem 0.35rem;
border: 1px solid #cbd5e1;
border: 1px solid var(--color-border);
border-radius: 5px;
font-size: 0.78rem;
text-align: right;
font-family: monospace;
background: white;
background: var(--color-surface);
color: var(--color-text);
}
.abo-input:focus {
outline: none;
@@ -1672,9 +1693,9 @@ function renderMarkdown(md: string): string {
}
/* ── SVG axes ── */
.axis-label { font-size: 10.5px; fill: #475569; font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-label-sm { font-size: 10px; fill: #64748b; font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-title { font-size: 11px; fill: #64748b; font-weight: 600; font-family: system-ui, -apple-system, sans-serif; }
.axis-label { font-size: 10.5px; fill: var(--svg-text); font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-label-sm { font-size: 10px; fill: var(--svg-text-light); font-weight: 500; font-family: system-ui, -apple-system, sans-serif; }
.axis-title { font-size: 11px; fill: var(--svg-text-light); font-weight: 600; font-family: system-ui, -apple-system, sans-serif; }
/* ── Chart containers ── */
.chart-container svg {
@@ -1757,10 +1778,10 @@ function renderMarkdown(md: string): string {
.section-title {
font-size: clamp(1rem, 2.5vw, 1.15rem);
font-weight: 700;
color: #1e293b;
color: var(--color-text);
margin: clamp(1rem, 3vw, 1.5rem) 0 clamp(0.75rem, 2vw, 1rem);
padding-bottom: 0.4rem;
border-bottom: 2px solid #e2e8f0;
border-bottom: 2px solid var(--color-border);
}
/* ── Params + impacts ── */
@@ -1804,8 +1825,7 @@ function renderMarkdown(md: string): string {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: clamp(0.5rem, 2vw, 1rem);
background: linear-gradient(135deg, #eff6ff, #f0fdf4);
border: 1px solid #e2e8f0;
border: 1px solid var(--color-border);
border-radius: clamp(8px, 2vw, 12px);
padding: clamp(0.75rem, 2vw, 1.25rem) clamp(0.75rem, 2vw, 1.5rem);
}
@@ -1823,11 +1843,11 @@ function renderMarkdown(md: string): string {
.key-metric-value {
font-size: clamp(1.1rem, 3vw, 1.4rem);
font-weight: 700;
color: #1e293b;
color: var(--color-text);
}
.key-metric-label {
font-size: clamp(0.68rem, 1.6vw, 0.78rem);
color: #64748b;
color: var(--color-text-muted);
font-weight: 500;
}
.key-metric-tag {
@@ -1837,14 +1857,15 @@ function renderMarkdown(md: string): string {
letter-spacing: 0.05em;
padding: 0.1rem 0.5rem;
border-radius: 20px;
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.key-metric-tag-calc {
background: #eff6ff;
color: #2563eb;
border-color: #bfdbfe;
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
opacity: 0.85;
}
.key-metric-input {
opacity: 0.85;
@@ -1866,12 +1887,12 @@ function renderMarkdown(md: string): string {
align-items: center;
font-size: clamp(0.72rem, 1.8vw, 0.8rem);
padding: 0.2rem 0;
color: #475569;
color: var(--color-text-muted);
}
.vote-result-row strong {
font-family: monospace;
font-size: 0.88rem;
color: #1e293b;
color: var(--color-text);
}
/* ── Alerts ── */

View File

@@ -24,7 +24,7 @@
</form>
<!-- Dev credentials hint -->
<div v-if="isDev" style="margin-top: 1rem; padding: 0.75rem; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; font-size: 0.8rem;">
<div v-if="isDev" class="dev-hint">
<strong>Dev:</strong> superadmin@sejeteralo.fr / superadmin
</div>
</div>

View File

@@ -24,7 +24,7 @@
</form>
<!-- Dev credentials hint -->
<div v-if="isDev" style="margin-top: 1rem; padding: 0.75rem; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; font-size: 0.8rem;">
<div v-if="isDev" class="dev-hint">
<strong>Dev:</strong> saou@sejeteralo.fr / saou2024
</div>
</div>