Statistik-Icon-Groesse, Batteriediagramm-Ueberarbeitung, Temperatur-Kopplung mit 300s-Fenster, letzte gueltige Spannung als Fallback
- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch anschliessend 10% groesser. - Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel, kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator- spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve). - Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch wenn sich der Tagesminimalwert selbst nicht aendert. - "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle offline) die zuletzt gemessene Spannung statt "unbekannt". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,11 @@
|
||||
*
|
||||
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
|
||||
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
|
||||
* anhebt); Werte über 12,8 V gelten als „während der Fahrt gemessen" und
|
||||
* anhebt); Werte über 13,0 V gelten als „während der Fahrt gemessen" und
|
||||
* taugen nicht als Ruhewert.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { IconButton, Tile } from "@audi-dash/ui"
|
||||
|
||||
@@ -28,29 +28,46 @@ interface Tageswert {
|
||||
max: number | null
|
||||
min_ts?: string
|
||||
max_ts?: string
|
||||
min_temp_c?: number | null
|
||||
}
|
||||
|
||||
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
|
||||
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. Auch
|
||||
von BatterieListe.tsx verwendet, für die "Generatorspannung"-Kennzeichnung
|
||||
dort - eine einzige Schwelle für beide Ansichten. */
|
||||
export const AGM_RUHE_MAX_V = 12.8
|
||||
/** Ab hier gilt eine Messung nicht mehr als Batterie(ruhe)spannung, sondern
|
||||
schon als Generatorspannung - vom Nutzer direkt festgelegt ("max voltage
|
||||
for AGM Battery is 13V, everything above is the generator"), identisch
|
||||
mit AGM_RUHE_MAX_V im Panel (audi-dashboard-app.js). Getrennt von
|
||||
LADEKURVE unten: eine Messung zwischen 12,8 und 13,0 V zählt hier noch als
|
||||
gültige Batteriemessung (Diagrammpunkt, fließt in den Trend ein), zeigt
|
||||
aber weiterhin 100 % Ladezustand ("12,8v voll was already right" - der
|
||||
Nutzer bestätigte den ursprünglichen 12,8-V-Eckwert ausdrücklich). Auch
|
||||
von BatterieListe.tsx verwendet, für die "Generatorspannung"-
|
||||
Kennzeichnung dort - eine einzige Schwelle für beide Ansichten. */
|
||||
export const AGM_RUHE_MAX_V = 13.0
|
||||
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe, linear zwischen den
|
||||
Stützstellen interpoliert - identische Tabelle und Rechenweg wie im Panel
|
||||
(`bvSocProzent()`/`BV_SOC_TABELLE`, `audi-dashboard-app.js`), auf
|
||||
Nutzerwunsch angeglichen: die vorherige Stufenkurve hier lieferte bei
|
||||
derselben Spannung teils deutlich andere Prozentwerte als das Panel (z. B.
|
||||
12,1 V: 40 % im Panel, 25 % hier) - beide Ansichten sollen bei derselben
|
||||
Messung dasselbe zeigen. Letzte Stützstelle bleibt bei 12,8 V/100 % -
|
||||
unabhängig von der oben auf 13,0 V angehobenen AGM_RUHE_MAX_V, siehe
|
||||
deren eigenen Kommentar. */
|
||||
const LADEKURVE: [number, number][] = [
|
||||
[12.8, 100],
|
||||
[12.6, 90],
|
||||
[12.4, 75],
|
||||
[12.2, 50],
|
||||
[12.0, 25],
|
||||
[11.8, 10],
|
||||
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
|
||||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||||
]
|
||||
|
||||
function ladezustand(spannung: number): number | null {
|
||||
if (spannung > AGM_RUHE_MAX_V) return null
|
||||
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
|
||||
if (ersterTreffer) return ersterTreffer[1]
|
||||
return 0
|
||||
const t = LADEKURVE
|
||||
if (spannung <= t[0]![0]) return 0
|
||||
if (spannung >= t[t.length - 1]![0]) return 100
|
||||
for (let i = 0; i < t.length - 1; i++) {
|
||||
const [v0, s0] = t[i]!
|
||||
const [v1, s1] = t[i + 1]!
|
||||
if (spannung >= v0 && spannung <= v1) return Math.round(s0 + ((s1 - s0) * (spannung - v0)) / (v1 - v0))
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
@@ -148,11 +165,34 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
|
||||
/** Von Hand gezeichnete Verlaufskurve aus dem Tagesminimum, mit einem Punkt je
|
||||
Messung. Der Tageshöchstwert bekommt bewusst KEINE eigene Linie mehr
|
||||
(Nutzerwunsch, Parität zum Panel) - er erscheint als Text, sobald ein Punkt
|
||||
angetippt wird, zusammen mit Datum und Uhrzeit.
|
||||
|
||||
Die viewBox folgt der tatsächlich gemessenen Pixelbreite, statt eine feste
|
||||
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
|
||||
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
|
||||
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
|
||||
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const breite = 320
|
||||
const hoehe = 120
|
||||
const rand = 4
|
||||
const rand = 8
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const [breite, setzeBreite] = useState(320)
|
||||
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current
|
||||
if (!el || typeof ResizeObserver === "undefined") return
|
||||
const messen = () => {
|
||||
const w = el.getBoundingClientRect().width
|
||||
if (w > 0) setzeBreite(w)
|
||||
}
|
||||
messen()
|
||||
const beobachter = new ResizeObserver(messen)
|
||||
beobachter.observe(el)
|
||||
return () => beobachter.disconnect()
|
||||
}, [])
|
||||
|
||||
// Feste Achse 10-15V (Nutzerwunsch): deckt den relevanten Bereich ab
|
||||
// (12V-Bleibatterie, Ladespannung des Alternators mit Reserve).
|
||||
@@ -160,8 +200,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const groesster = 15
|
||||
const spanne = groesster - kleinster
|
||||
|
||||
const x = (i: number) =>
|
||||
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
|
||||
const x = (i: number) => rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
|
||||
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
|
||||
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
|
||||
const y = (v: number) => {
|
||||
@@ -169,25 +208,62 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
return hoehe - rand - anteil * (hoehe - 2 * rand)
|
||||
}
|
||||
|
||||
const linie = (auswahl: (t: Tageswert) => number | null) =>
|
||||
werte
|
||||
.map((t, i) => {
|
||||
const v = auswahl(t)
|
||||
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
const linie = werte
|
||||
.map((t, i) => (t.min == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
const punktWaehlen = (ereignis: { clientX: number }) => {
|
||||
const el = svgRef.current
|
||||
if (!el || werte.length === 0) return
|
||||
const kasten = el.getBoundingClientRect()
|
||||
const relativ = ereignis.clientX - kasten.left - rand
|
||||
const schritt = (breite - 2 * rand) / Math.max(1, werte.length - 1)
|
||||
const index = Math.max(0, Math.min(werte.length - 1, Math.round(relativ / schritt)))
|
||||
setzeGewaehlt((vorher) => (vorher === index ? null : index))
|
||||
}
|
||||
|
||||
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
|
||||
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite} ${hoehe}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
|
||||
>
|
||||
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
|
||||
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite.toFixed(1)} ${hoehe}`}
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
|
||||
onClick={punktWaehlen}
|
||||
>
|
||||
<path d={linie} fill="none" stroke="var(--red)" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
{werte.map((t, i) =>
|
||||
t.min == null ? null : (
|
||||
<circle
|
||||
key={t.datum}
|
||||
cx={x(i)}
|
||||
cy={y(t.min)}
|
||||
r={gewaehlt === i ? 4.5 : 2.4}
|
||||
fill="var(--red)"
|
||||
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
{aktiv && (
|
||||
<p className="dm-fussnote">
|
||||
{[
|
||||
zeitpunkt
|
||||
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
|
||||
: datum(aktiv.datum),
|
||||
aktiv.min != null ? `Min ${de(aktiv.min, 2)} V` : null,
|
||||
aktiv.max != null ? `Max ${de(aktiv.max, 2)} V` : null,
|
||||
aktiv.min_temp_c != null ? `${de(aktiv.min_temp_c, 1)} °C` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,14 +79,21 @@ export function SymbolFahrten({ groesse }: SymbolProps) {
|
||||
}
|
||||
|
||||
// CI-Symbol polls-s statt der vorherigen handgezeichneten Balken (Parität
|
||||
// zum Panel, siehe ICONS.stat dort).
|
||||
// zum Panel, siehe ICONS.stat dort). Anders als die handgezeichneten
|
||||
// Symbole (die im 24er-Raster deutlich Rand lassen) ist das CI-Symbol
|
||||
// randlos gezeichnet und wirkte dadurch in der Tableiste sichtbar größer
|
||||
// als "Fahrten"/"Tanken" - dieselbe zentrierte Stauchung (Faktor 0.77, um
|
||||
// (12,12), auf Nutzerwunsch 10% größer als die erste Fassung) wie im Panel
|
||||
// gleicht das aus, siehe ICONS.stat dort.
|
||||
export function SymbolStatistik({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
voll
|
||||
kinder={
|
||||
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
|
||||
<g transform="translate(2.76,2.76) scale(0.77)">
|
||||
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user