Compare commits

5 Commits

Author SHA1 Message Date
nicoboy db964d44b2 amelioration courbe bleue prix 2026-05-01 22:22:11 +02:00
nicoboy b5d58911fb modification rendu courbe bleue prix moyen 2026-05-01 21:54:36 +02:00
nicoboy 8b1dd9b170 seuillage prix moyen >30 2026-05-01 21:26:16 +02:00
nicoboy daad6bb1d5 modif position legende 2026-05-01 20:37:23 +02:00
nicoboy 129c9cfc7b modification calcul mediane 2026-05-01 20:22:52 +02:00
9 changed files with 189 additions and 162 deletions
-7
View File
@@ -87,13 +87,6 @@ pinia@^3.0.2 # State management
typescript@^5.8.2 # Typage statique
```
Le frontend étend le layer partagé **`../../yvv-nuxt-base`** (`extends` dans
`nuxt.config.ts`) : le composable `useMood` y centralise l'application DOM des
palettes (variables CSS + classe `.palette-dark`), la persistance localStorage et
le light/dark. Les palettes propres à SejeteralO (6 ambiances + variables SVG des
graphiques) sont définies côté projet dans `app/composables/usePalette.ts`, qui
délègue à `useMood` et synchronise l'état partagé `useState('theme-dark')`.
## Base de donnees
**SQLite** en dev/production legere, configurable via `DATABASE_URL`.
+44 -6
View File
@@ -5,12 +5,22 @@ Computes the element-wise median of (vinf, a, b, c, d, e) across all active vote
This parametric median is chosen over geometric median because:
- It's transparent and politically explainable
- The result is itself a valid set of Bézier parameters
Post-processing: the median is sanitized to guarantee monotonicity of tier 2.
The condition for tier 2 to be non-decreasing is e <= 1/3.
If the raw median violates this, e is clamped to E_MAX_MONOTONE.
"""
import numpy as np
from dataclasses import dataclass
# Maximum value of e that guarantees tier-2 price monotonicity.
# prix_m3_2 = p0 + (pmax-p0) * ((1-3e)t³ + 3e t²)
# The cubic is monotone non-decreasing on [0,1] iff (1-3e) >= 0, i.e. e <= 1/3.
E_MAX_MONOTONE = 1.0 / 3.0
@dataclass
class VoteParams:
"""The 6 citizen-adjustable parameters."""
@@ -22,23 +32,49 @@ class VoteParams:
e: float
def _is_tier2_monotone(e: float) -> bool:
"""Check if tier-2 price curve is monotone non-decreasing."""
return e <= E_MAX_MONOTONE
def sanitize_median(params: "VoteParams") -> "VoteParams":
"""
Ensure the median curve is physically valid:
- Tier 2 must be monotone non-decreasing (penalizes high consumption)
- If e violates monotonicity, clamp it to E_MAX_MONOTONE
"""
e = params.e
if not _is_tier2_monotone(e):
e = E_MAX_MONOTONE
return VoteParams(
vinf=params.vinf,
a=params.a,
b=params.b,
c=params.c,
d=params.d,
e=e,
)
def compute_median(votes: list[VoteParams]) -> VoteParams | None:
"""
Compute element-wise median of vote parameters.
Returns None if no votes provided.
The result is sanitized for tier-2 monotonicity.
"""
if not votes:
return None
vinfs = [v.vinf for v in votes]
a_s = [v.a for v in votes]
b_s = [v.b for v in votes]
c_s = [v.c for v in votes]
d_s = [v.d for v in votes]
e_s = [v.e for v in votes]
a_s = [v.a for v in votes]
b_s = [v.b for v in votes]
c_s = [v.c for v in votes]
d_s = [v.d for v in votes]
e_s = [v.e for v in votes]
return VoteParams(
raw = VoteParams(
vinf=float(np.median(vinfs)),
a=float(np.median(a_s)),
b=float(np.median(b_s)),
@@ -46,3 +82,5 @@ def compute_median(votes: list[VoteParams]) -> VoteParams | None:
d=float(np.median(d_s)),
e=float(np.median(e_s)),
)
return sanitize_median(raw)
+47
View File
@@ -0,0 +1,47 @@
# docker-compose.jetson.yml
# Ports décalés (8001/3001) pour ne pas entrer en conflit
# avec les services existants sur le Jetson.
# Nginx fait le reverse proxy depuis sejeteraleau.nicolasboyer.com
services:
backend:
build:
context: .
dockerfile: docker/backend.Dockerfile
target: production
environment:
DATABASE_URL: sqlite+aiosqlite:////data/sejeteralo.db
SECRET_KEY: CHANGEZ-MOI-cle-longue-et-aleatoire-32-chars-min
DEBUG: "false"
CORS_ORIGINS: '["https://sejeteraleau.nicolasboyer.com"]'
ports:
- "127.0.0.1:8010:8000"
volumes:
- backend-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 5s
retries: 3
frontend:
build:
context: .
dockerfile: docker/frontend.Dockerfile
target: production
args:
# URL vue depuis le navigateur du visiteur
NUXT_PUBLIC_API_BASE: https://sejeteraleau.nicolasboyer.com/api/v1
environment:
NUXT_PUBLIC_API_BASE: https://sejeteraleau.nicolasboyer.com/api/v1
PORT: "3000"
ports:
- "127.0.0.1:3010:3000"
depends_on:
- backend
restart: unless-stopped
volumes:
backend-data:
+90 -4
View File
@@ -61,8 +61,8 @@
</template>
<script setup lang="ts">
// Palette : identité + application DOM/persistance/dark déléguées à useMood.
const { paletteList, currentPalette, setPalette, initPalette } = usePalette()
// 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)
@@ -101,6 +101,92 @@ function setDensity(d: string) {
}
}
// ── 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 = paletteList.find(x => x.name === name)
if (!p || !import.meta.client) return
localStorage.setItem('sej-palette', name)
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 {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const f = 0.82
return `#${Math.round(r * f).toString(16).padStart(2, '0')}${Math.round(g * f).toString(16).padStart(2, '0')}${Math.round(b * f).toString(16).padStart(2, '0')}`
}
// ── Init from localStorage ──
onMounted(() => {
if (!import.meta.client) return
@@ -108,8 +194,8 @@ onMounted(() => {
if (savedFont) setFontSize(savedFont)
const savedDensity = localStorage.getItem('sej-density')
if (savedDensity) setDensity(savedDensity)
// Palette : restauration + application déléguées au composable partagé.
initPalette()
const savedPalette = localStorage.getItem('sej-palette')
if (savedPalette) setPalette(savedPalette)
})
// ── Click outside to close ──
-131
View File
@@ -1,131 +0,0 @@
import type { MoodDef } from '#imports'
/**
* Palettes SejeteralO — identité couleur du projet.
* L'application DOM (variables CSS + classe .palette-dark), la persistance et
* le light/dark sont délégués au mécanisme partagé `useMood` (@yvv/nuxt-base).
* Chaque palette injecte AUSSI ses variables SVG (thème des graphiques) et un
* `--color-primary-dark` dérivé — d'où le précalcul en `vars`.
*/
export interface Palette {
name: string
label: string
dark: boolean
primary: string
accent: string
bg: string
surface: string
text: string
textMuted: string
border: string
// SVG-specific (thème des graphiques)
svgPlotBg: string
svgGrid: string
svgLegendBg: string
svgText: string
svgTextLight: string
}
export 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',
},
]
/** Assombrit une couleur hex (facteur 0.82) — variante `--color-primary-dark`. */
function darken(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const f = 0.82
return `#${Math.round(r * f).toString(16).padStart(2, '0')}${Math.round(g * f).toString(16).padStart(2, '0')}${Math.round(b * f).toString(16).padStart(2, '0')}`
}
const paletteMoods: MoodDef[] = paletteList.map(p => ({
id: p.name,
label: p.label,
isDark: p.dark,
vars: {
'--color-primary': p.primary,
'--color-primary-dark': darken(p.primary),
'--color-secondary': p.accent,
'--color-bg': p.bg,
'--color-surface': p.surface,
'--color-text': p.text,
'--color-text-muted': p.textMuted,
'--color-border': p.border,
// Thème SVG (graphiques)
'--svg-plot-bg': p.svgPlotBg,
'--svg-grid': p.svgGrid,
'--svg-legend-bg': p.svgLegendBg,
'--svg-text': p.svgText,
'--svg-text-light': p.svgTextLight,
},
}))
export function usePalette() {
const { currentMood, setMood, initMood } = useMood(paletteMoods, {
storageKey: 'sej-palette',
defaultId: 'eau',
syncColorMode: false, // pas de @nuxtjs/color-mode
lightDarkClasses: false, // le CSS ne stylise que .palette-dark
darkClass: 'palette-dark',
})
// État dark partagé, lu par la page graphiques (commune/[slug]) pour le thème SVG.
const isDark = useState('theme-dark', () => false)
function syncDark(id: string) {
const m = paletteMoods.find(x => x.id === id)
if (m) isDark.value = m.isDark
}
const currentPalette = computed(() => currentMood.value || 'eau')
function setPalette(name: string) {
setMood(name)
syncDark(name)
}
function initPalette() {
initMood()
syncDark(currentMood.value || 'eau')
}
return { paletteList, currentPalette, setPalette, initPalette, isDark }
}
+8 -6
View File
@@ -231,7 +231,7 @@
</text>
<!-- Legend box (top-right) -->
<g :transform="`translate(${margin.left + plotW - 232}, ${margin.top + 8})`">
<g :transform="`translate(${margin.left + 8}, ${margin.top + plotH - 90})`">
<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="t.text">Consommations foyers</text>
@@ -1295,14 +1295,16 @@ const gridPrices2 = computed(() => {
return arr
})
function toPolyline(vols: number[] | undefined, vals: number[] | undefined, cyFn: (v: number) => number): string {
function toPolyline(vols: number[] | undefined, vals: number[] | undefined, cyFn: (v: number) => number, minVol = 15): string {
if (!vols?.length || !vals?.length) return ''
const pts: string[] = []
for (let i = 0; i < vols.length; i += 4) {
pts.push(`${cx2(vols[i]!)},${cyFn(vals[i]!)}`)
const v = vols[i]!
if (v < minVol) continue
pts.push(`${cx2(v)},${cyFn(vals[i]!)}`)
}
const last = vols.length - 1
if (last % 4 !== 0) pts.push(`${cx2(vols[last]!)},${cyFn(vals[last]!)}`)
if (last % 4 !== 0 && vols[last]! >= minVol) pts.push(`${cx2(vols[last]!)},${cyFn(vals[last]!)}`)
return pts.join(' ')
}
@@ -1391,7 +1393,7 @@ const margPriceLine = computed(() => {
const pts: string[] = []
for (let i = 0; i < vols.length; i += 3) {
const v = vols[i]!
if (v > margVolMax.value) break
if (v < 15 || v > margVolMax.value) continue // ← changer break par continue, et ajouter v < 15
pts.push(`${margCx(v)},${margCyPrice(prices[i]!)}`)
}
return pts.join(' ')
@@ -1406,7 +1408,7 @@ const margAvgPriceWithAboLine = computed(() => {
const pts: string[] = []
for (let i = 0; i < vols.length; i += 3) {
const v = vols[i]!
if (v < 1 || v > margVolMax.value) continue
if (v < 15 || v > margVolMax.value) continue
const avgP = citizenAbo.value / v + p0lin
if (avgP <= margPriceMax.value * 1.5) {
pts.push(`${margCx(v)},${margCyPrice(avgP)}`)
-2
View File
@@ -1,6 +1,4 @@
export default defineNuxtConfig({
extends: ['@yvv/nuxt-base'],
compatibilityDate: '2025-01-01',
future: { compatibilityVersion: 4 },
-5
View File
@@ -8,7 +8,6 @@
"name": "sejeteralo-frontend",
"version": "0.1.0",
"dependencies": {
"@yvv/nuxt-base": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0",
"nuxt": "^4.3.1",
"vue": "^3.5.28",
"vue-router": "^5.0.3"
@@ -3833,10 +3832,6 @@
"integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==",
"license": "MIT"
},
"node_modules/@yvv/nuxt-base": {
"version": "0.1.0",
"resolved": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#b1c6a2f28052564d505dd05b363169cb97fb81b3"
},
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
-1
View File
@@ -9,7 +9,6 @@
"generate": "nuxt generate"
},
"dependencies": {
"@yvv/nuxt-base": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0",
"nuxt": "^4.3.1",
"vue": "^3.5.28",
"vue-router": "^5.0.3"