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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
63
.woodpecker.yml
Normal 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
|
||||
15
Makefile
15
Makefile
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
38
docker/backend.Dockerfile
Normal 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"]
|
||||
26
docker/docker-compose.dev.yml
Normal file
26
docker/docker-compose.dev.yml
Normal 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
43
docker/docker-compose.yml
Normal 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:
|
||||
36
docker/frontend.Dockerfile
Normal file
36
docker/frontend.Dockerfile
Normal 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" ]
|
||||
@@ -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"]
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 (m³)
|
||||
</text>
|
||||
<text :x="6" :y="12" font-size="11" fill="#64748b">€/m³</text>
|
||||
<text :x="6" :y="12" font-size="11" fill="var(--svg-text-light)">€/m³</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);
|
||||
}
|
||||
|
||||
@@ -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/30m³</text>
|
||||
text-anchor="start" font-size="8.5" :fill="t.textMuted">foyers/30m³</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) }} €/m³
|
||||
</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) }}€/m³)
|
||||
</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 (60m³)
|
||||
</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) }} €/m³</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">m³ → 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 m³ (€)</h4>
|
||||
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem; color: var(--color-text-muted);">Prix au m³ (€)</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) }}€/m³
|
||||
</text>
|
||||
<text :x="W2/2" :y="margin2.top + plotH2 + 42" text-anchor="middle" class="axis-label-sm">m³ → 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 m³ + 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 m³ — 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 }}m³</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) }}€/m³
|
||||
</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 m³ avec abonnement (€/m³)</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 30m³</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 m³ avec abonnement (€/m³)</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 30m³</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 ── */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user