diff --git a/.gitignore b/.gitignore
index 8a54fde..e51de09 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,7 @@ backend/uploads/
htmlcov/
.coverage
coverage.xml
+
+# Sensitive dev files
+IDENTIFIANTS.txt
+data/DEV-CREDENTIALS.md
diff --git a/backend/app/routers/votes.py b/backend/app/routers/votes.py
index 35d1dbf..af20998 100644
--- a/backend/app/routers/votes.py
+++ b/backend/app/routers/votes.py
@@ -39,6 +39,23 @@ async def _load_commune_context(commune_id: int, db: AsyncSession):
return params, households
+# ── Public endpoint: overlay of all vote curves for citizens ──
+
+@router.get("/communes/{slug}/votes/current/overlay")
+async def current_overlay(slug: str, db: AsyncSession = Depends(get_db)):
+ """Public: returns all active vote curves (params only, no auth required)."""
+ commune = await _get_commune_by_slug(slug, db)
+ result = await db.execute(
+ select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
+ )
+ votes = result.scalars().all()
+ return [
+ {"vinf": v.vinf, "a": v.a, "b": v.b,
+ "c": v.c, "d": v.d, "e": v.e, "computed_p0": v.computed_p0}
+ for v in votes
+ ]
+
+
# ── Public endpoint: current median curve for citizens ──
@router.get("/communes/{slug}/votes/current")
diff --git a/frontend/app/app.vue b/frontend/app/app.vue
index 6a03359..f8eacfa 100644
--- a/frontend/app/app.vue
+++ b/frontend/app/app.vue
@@ -3,11 +3,3 @@
-
-
diff --git a/frontend/app/middleware/admin.ts b/frontend/app/middleware/admin.ts
index aec55b9..634ba62 100644
--- a/frontend/app/middleware/admin.ts
+++ b/frontend/app/middleware/admin.ts
@@ -6,6 +6,6 @@ export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated || !authStore.isAdmin) {
- return navigateTo('/login')
+ return navigateTo('/')
}
})
diff --git a/frontend/app/pages/commune/[slug]/index.vue b/frontend/app/pages/commune/[slug]/index.vue
index 8644a67..258dba4 100644
--- a/frontend/app/pages/commune/[slug]/index.vue
+++ b/frontend/app/pages/commune/[slug]/index.vue
@@ -23,21 +23,38 @@
Tarification progressive — Prix au m3
-
- Mediane de {{ curveData.vote_count }} vote(s)
-
-
Courbe par defaut
+
+
+
+ {{ curveData.vote_count }} vote(s)
+
+ Courbe par defaut
+
-
- Deplacez les poignees pour ajuster la forme de la courbe.
- Le prix d'inflexion p0 s'ajuste automatiquement pour equilibrer les recettes.
+
+ Deplacez les poignees pour ajuster la courbe.
+ p0 s'equilibre automatiquement.
+
+
+
+
+
+
+
+
+ {{ zoomVolMin.toFixed(0) }}-{{ zoomVolMax.toFixed(0) }} m3 / {{ zoomPriceMin.toFixed(1) }}-{{ zoomPriceMax.toFixed(1) }} EUR
+
+
+
@@ -50,34 +67,40 @@
@mouseleave="onMouseUp"
@touchmove.prevent="onTouchMove"
@touchend="onMouseUp"
+ @wheel.prevent="onWheel"
>
-
{{ v }}
-
- {{ p }}
+ {{ p.toFixed(p < 1 ? 1 : 0) }}
-
volume (m3)
- Prix/m3
+ EUR/m3
+
+
+
+
+
+
+
+
@@ -88,19 +111,19 @@
-
-
+
+
-
+
-
-
-
+
p0 = {{ localP0.toFixed(2) }} EUR/m3
@@ -113,7 +136,6 @@
@mousedown.prevent="startDrag(key)"
@touchstart.prevent="startDrag(key)"
/>
-
@@ -345,28 +367,79 @@ const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlu
const submitting = ref(false)
const voteSuccess = ref(false)
-// ── Chart 1: Interactive Bezier ──
+// ── Chart 1: Interactive Bezier with zoom ──
const W = 620
const H = 380
const margin = { top: 20, right: 20, bottom: 28, left: 45 }
const plotW = W - margin.left - margin.right
const plotH = H - margin.top - margin.bottom
-function cx(v: number) { return margin.left + (v / vmax.value) * plotW }
-function cy(p: number) { return margin.top + plotH - (p / pmax.value) * plotH }
-function fromX(sx: number) { return ((sx - margin.left) / plotW) * vmax.value }
-function fromY(sy: number) { return ((margin.top + plotH - sy) / plotH) * pmax.value }
+// Zoom state (data-space ranges)
+const zoomVolMin = ref(0)
+const zoomVolMax = ref(2100)
+const zoomPriceMin = ref(0)
+const zoomPriceMax = ref(20)
+
+// Outlier overlay
+const showOutliers = ref(false)
+const outlierVotes = ref([])
+
+// Zoom-aware coordinate transforms
+function cx(v: number) { return margin.left + ((v - zoomVolMin.value) / (zoomVolMax.value - zoomVolMin.value)) * plotW }
+function cy(p: number) { return margin.top + plotH - ((p - zoomPriceMin.value) / (zoomPriceMax.value - zoomPriceMin.value)) * plotH }
+function fromX(sx: number) { return zoomVolMin.value + ((sx - margin.left) / plotW) * (zoomVolMax.value - zoomVolMin.value) }
+function fromY(sy: number) { return zoomPriceMin.value + ((margin.top + plotH - sy) / plotH) * (zoomPriceMax.value - zoomPriceMin.value) }
+
+function zoomIn() {
+ const dv = (zoomVolMax.value - zoomVolMin.value) * 0.15
+ const dp = (zoomPriceMax.value - zoomPriceMin.value) * 0.15
+ zoomVolMin.value += dv; zoomVolMax.value -= dv
+ zoomPriceMin.value += dp; zoomPriceMax.value -= dp
+}
+function zoomOut() {
+ const dv = (zoomVolMax.value - zoomVolMin.value) * 0.2
+ const dp = (zoomPriceMax.value - zoomPriceMin.value) * 0.2
+ zoomVolMin.value = Math.max(0, zoomVolMin.value - dv)
+ zoomVolMax.value = Math.min(vmax.value, zoomVolMax.value + dv)
+ zoomPriceMin.value = Math.max(0, zoomPriceMin.value - dp)
+ zoomPriceMax.value = Math.min(pmax.value, zoomPriceMax.value + dp)
+}
+function zoomReset() {
+ zoomVolMin.value = 0; zoomVolMax.value = vmax.value
+ zoomPriceMin.value = 0; zoomPriceMax.value = pmax.value
+}
+function zoomPreset(tier: string) {
+ if (tier === 'tier1') {
+ zoomVolMin.value = 0
+ zoomVolMax.value = Math.min(bp.vinf * 1.3, vmax.value)
+ zoomPriceMin.value = 0
+ zoomPriceMax.value = Math.min(localP0.value * 1.5, pmax.value)
+ } else {
+ zoomVolMin.value = Math.max(0, bp.vinf * 0.8)
+ zoomVolMax.value = vmax.value
+ zoomPriceMin.value = Math.max(0, localP0.value * 0.7)
+ zoomPriceMax.value = pmax.value
+ }
+}
+function onWheel(e: WheelEvent) {
+ if (e.deltaY < 0) zoomIn()
+ else zoomOut()
+}
const gridVols = computed(() => {
- const step = Math.ceil(vmax.value / 7 / 100) * 100
+ const range = zoomVolMax.value - zoomVolMin.value
+ const step = Math.max(10, Math.ceil(range / 7 / (range > 500 ? 100 : 10)) * (range > 500 ? 100 : 10))
const arr: number[] = []
- for (let v = step; v < vmax.value; v += step) arr.push(v)
+ const start = Math.ceil(zoomVolMin.value / step) * step
+ for (let v = start; v < zoomVolMax.value; v += step) arr.push(v)
return arr
})
const gridPrices = computed(() => {
- const step = Math.ceil(pmax.value / 5)
+ const range = zoomPriceMax.value - zoomPriceMin.value
+ const step = range > 5 ? Math.ceil(range / 5) : range > 1 ? 1 : 0.5
const arr: number[] = []
- for (let p = step; p <= pmax.value; p += step) arr.push(p)
+ const start = Math.ceil(zoomPriceMin.value / step) * step
+ for (let p = start; p <= zoomPriceMax.value; p += step) arr.push(p)
return arr
})
@@ -399,6 +472,16 @@ const tier2Path = computed(() => {
return `M ${cx(c.p4.x)} ${cy(c.p4.y)} C ${cx(c.p5.x)} ${cy(c.p5.y)}, ${cx(c.p6.x)} ${cy(c.p6.y)}, ${cx(c.p7.x)} ${cy(c.p7.y)}`
})
+// Outlier vote paths
+function outlierPath(vote: any, tier: number): string {
+ const p0 = vote.computed_p0 || localP0.value
+ const oc = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e)
+ if (tier === 1) {
+ return `M ${cx(oc.p1.x)} ${cy(oc.p1.y)} C ${cx(oc.p2.x)} ${cy(oc.p2.y)}, ${cx(oc.p3.x)} ${cy(oc.p3.y)}, ${cx(oc.p4.x)} ${cy(oc.p4.y)}`
+ }
+ return `M ${cx(oc.p4.x)} ${cy(oc.p4.y)} C ${cx(oc.p5.x)} ${cy(oc.p5.y)}, ${cx(oc.p6.x)} ${cy(oc.p6.y)}, ${cx(oc.p7.x)} ${cy(oc.p7.y)}`
+}
+
// ── Drag handling ──
const svgRef = ref(null)
const dragging = ref(null)
@@ -417,7 +500,7 @@ function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handle
function onMouseUp() {
if (dragging.value) {
dragging.value = null
- debouncedServerCompute()
+ serverCompute()
}
}
@@ -451,29 +534,12 @@ function handleDrag(pt: { x: number; y: number }) {
break
}
}
- recalculate()
-}
-
-function recalculate() {
- if (!households.value.length) return
- localP0.value = computeP0(
- households.value, recettes.value, abop.value, abos.value,
- bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
- )
- const result = computeImpacts(
- households.value, recettes.value, abop.value, abos.value,
- bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
- )
- impacts.value = result.impacts.map(imp => ({
- volume: imp.volume,
- old_price: imp.oldPrice,
- new_price_rp: imp.newPriceRP,
- new_price_rs: imp.newPriceRS,
- }))
+ // p0 stays fixed during drag — curve updates reactively via bp → cp → SVG paths
+ // Server will compute authoritative p0 + impacts on mouseUp
}
let serverTimeout: ReturnType | null = null
-function debouncedServerCompute() {
+function serverCompute() {
if (serverTimeout) clearTimeout(serverTimeout)
serverTimeout = setTimeout(async () => {
try {
@@ -489,7 +555,7 @@ function debouncedServerCompute() {
new_price_rs: imp.new_price_rs,
}))
} catch {}
- }, 300)
+ }, 150)
}
// ── Chart 2: Baseline linear model ──
@@ -593,12 +659,14 @@ onMounted(async () => {
params.value = p
curveData.value = curve
- // Set tariff params
+ // Set tariff params + zoom
vmax.value = p.vmax
pmax.value = p.pmax
recettes.value = p.recettes
abop.value = p.abop
abos.value = p.abos
+ zoomVolMax.value = p.vmax
+ zoomPriceMax.value = p.pmax
// Set initial Bezier params from median (or default)
if (curve.median) {
@@ -622,6 +690,11 @@ onMounted(async () => {
for (let i = 0; i < (stats.rp_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RP' })
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
households.value = hh
+
+ // Load all vote curves for outlier overlay (public endpoint needed)
+ try {
+ outlierVotes.value = await api.get(`/communes/${slug}/votes/current/overlay`)
+ } catch { /* endpoint may not exist yet */ }
} catch (e: any) {
loadError.value = e.message
} finally {
@@ -648,6 +721,31 @@ function renderMarkdown(md: string): string {
diff --git a/frontend/app/plugins/auth-restore.client.ts b/frontend/app/plugins/auth-restore.client.ts
new file mode 100644
index 0000000..fe677ee
--- /dev/null
+++ b/frontend/app/plugins/auth-restore.client.ts
@@ -0,0 +1,8 @@
+/**
+ * Client-only plugin: restores auth state from localStorage
+ * BEFORE route middleware runs.
+ */
+export default defineNuxtPlugin(() => {
+ const authStore = useAuthStore()
+ authStore.restore()
+})