Add roadmap and implement top UX improvements for score transparency and map selection
This commit is contained in:
parent
a118c3ca33
commit
c0ea660e48
6 changed files with 324 additions and 16 deletions
|
|
@ -476,6 +476,98 @@ def source_health(sources: list[dict], now: dt.datetime) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_quality(
|
||||||
|
health: dict, used_fallback_pois: bool, degraded: bool, factor_count: int, community_factor_count: int
|
||||||
|
) -> dict:
|
||||||
|
score = 100
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if degraded:
|
||||||
|
score -= 45
|
||||||
|
reasons.append("Backend im degradierten Modus")
|
||||||
|
if used_fallback_pois:
|
||||||
|
score -= 20
|
||||||
|
reasons.append("Fallback-POIs statt Live-POIs")
|
||||||
|
if not health.get("has_data"):
|
||||||
|
score -= 20
|
||||||
|
reasons.append("Keine Quellenmetadaten")
|
||||||
|
else:
|
||||||
|
stalest_age = health.get("stalest_age_hours")
|
||||||
|
if isinstance(stalest_age, (int, float)):
|
||||||
|
if stalest_age > 48:
|
||||||
|
score -= 20
|
||||||
|
reasons.append("Mindestens eine Quelle >48h alt")
|
||||||
|
elif stalest_age > 24:
|
||||||
|
score -= 10
|
||||||
|
reasons.append("Mindestens eine Quelle >24h alt")
|
||||||
|
stale_count = len(health.get("stale_sources") or [])
|
||||||
|
if stale_count >= 2:
|
||||||
|
score -= 10
|
||||||
|
reasons.append("Mehrere stale Quellen")
|
||||||
|
elif stale_count == 1:
|
||||||
|
score -= 5
|
||||||
|
reasons.append("Eine stale Quelle")
|
||||||
|
|
||||||
|
if factor_count <= 2:
|
||||||
|
score -= 8
|
||||||
|
reasons.append("Wenig Score-Faktoren verfuegbar")
|
||||||
|
if community_factor_count == 0:
|
||||||
|
score -= 5
|
||||||
|
reasons.append("Keine aktuellen Community-Signale")
|
||||||
|
elif community_factor_count >= 2:
|
||||||
|
score += 4
|
||||||
|
reasons.append("Mehrere aktuelle Community-Signale")
|
||||||
|
|
||||||
|
score = max(0, min(100, int(round(score))))
|
||||||
|
if score >= 75:
|
||||||
|
level = "high"
|
||||||
|
label = "hoch"
|
||||||
|
elif score >= 45:
|
||||||
|
level = "medium"
|
||||||
|
label = "mittel"
|
||||||
|
else:
|
||||||
|
level = "low"
|
||||||
|
label = "niedrig"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"level": level,
|
||||||
|
"label": label,
|
||||||
|
"reasons": reasons[:4],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_explanation(factors: list[dict], spot: dict) -> dict:
|
||||||
|
details = []
|
||||||
|
for item in factors:
|
||||||
|
points = float(item["points"])
|
||||||
|
impact = "neutral"
|
||||||
|
if points > 0:
|
||||||
|
impact = "positive"
|
||||||
|
elif points < 0:
|
||||||
|
impact = "negative"
|
||||||
|
details.append(
|
||||||
|
{
|
||||||
|
"key": item["key"],
|
||||||
|
"label": item["label"],
|
||||||
|
"points": round(points, 2),
|
||||||
|
"source": item["source"],
|
||||||
|
"impact": impact,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"factors": details,
|
||||||
|
"spot_context": {
|
||||||
|
"area_type": spot.get("osm_area_type", "unknown"),
|
||||||
|
"road_type": spot.get("road_type", "unknown"),
|
||||||
|
"distance_police_m": int(spot.get("distance_police_m", 5000)),
|
||||||
|
"distance_fire_m": int(spot.get("distance_fire_m", 5000)),
|
||||||
|
"distance_hospital_m": int(spot.get("distance_hospital_m", 5000)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict:
|
def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict:
|
||||||
now_iso = to_iso(utc_now())
|
now_iso = to_iso(utc_now())
|
||||||
spot = ensure_spot(lat, lon, now_iso)
|
spot = ensure_spot(lat, lon, now_iso)
|
||||||
|
|
@ -504,8 +596,16 @@ def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict:
|
||||||
raw_score = 100.0 + sum(item["points"] for item in factors)
|
raw_score = 100.0 + sum(item["points"] for item in factors)
|
||||||
score = clamp_score(raw_score)
|
score = clamp_score(raw_score)
|
||||||
|
|
||||||
top_reasons = sorted(factors, key=lambda item: abs(item["points"]), reverse=True)[:4]
|
sorted_factors = sorted(factors, key=lambda item: abs(item["points"]), reverse=True)
|
||||||
|
top_reasons = sorted_factors[:4]
|
||||||
reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons]
|
reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons]
|
||||||
|
quality = classify_quality(
|
||||||
|
health=health,
|
||||||
|
used_fallback_pois=bool(spot.get("used_fallback_pois", False)),
|
||||||
|
degraded=False,
|
||||||
|
factor_count=len(sorted_factors),
|
||||||
|
community_factor_count=sum(1 for item in factors if item.get("source") == "community"),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"spot_id": spot["id"],
|
"spot_id": spot["id"],
|
||||||
|
|
@ -513,6 +613,7 @@ def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict:
|
||||||
"ampel": ampel(score),
|
"ampel": ampel(score),
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"factors": top_reasons,
|
"factors": top_reasons,
|
||||||
|
"explanation": build_explanation(sorted_factors, spot),
|
||||||
"night_window": {
|
"night_window": {
|
||||||
"start": to_iso(night_start),
|
"start": to_iso(night_start),
|
||||||
"end": to_iso(night_end),
|
"end": to_iso(night_end),
|
||||||
|
|
@ -524,6 +625,7 @@ def compute_score_payload(lat: float, lon: float, at_time: dt.datetime) -> dict:
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
"health": health,
|
"health": health,
|
||||||
"used_fallback_pois": bool(spot.get("used_fallback_pois", False)),
|
"used_fallback_pois": bool(spot.get("used_fallback_pois", False)),
|
||||||
|
"quality": quality,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -550,8 +652,24 @@ def compute_score_payload_fallback(lat: float, lon: float, at_time: dt.datetime,
|
||||||
|
|
||||||
raw_score = 100.0 + sum(item["points"] for item in factors)
|
raw_score = 100.0 + sum(item["points"] for item in factors)
|
||||||
score = clamp_score(raw_score)
|
score = clamp_score(raw_score)
|
||||||
top_reasons = sorted(factors, key=lambda item: abs(item["points"]), reverse=True)[:4]
|
sorted_factors = sorted(factors, key=lambda item: abs(item["points"]), reverse=True)
|
||||||
|
top_reasons = sorted_factors[:4]
|
||||||
reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons] or ["Fallback-Berechnung aktiv (0)"]
|
reasons = [f"{item['label']} ({item['points']:+.0f})" for item in top_reasons] or ["Fallback-Berechnung aktiv (0)"]
|
||||||
|
fallback_spot = {
|
||||||
|
"osm_area_type": "residential",
|
||||||
|
"road_type": "unknown",
|
||||||
|
"distance_police_m": police_d,
|
||||||
|
"distance_fire_m": fire_d,
|
||||||
|
"distance_hospital_m": hosp_d,
|
||||||
|
}
|
||||||
|
health = {"freshest_age_hours": None, "stalest_age_hours": None, "stale_sources": [], "has_data": False}
|
||||||
|
quality = classify_quality(
|
||||||
|
health=health,
|
||||||
|
used_fallback_pois=True,
|
||||||
|
degraded=True,
|
||||||
|
factor_count=len(sorted_factors),
|
||||||
|
community_factor_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"spot_id": spot_id_for(lat, lon),
|
"spot_id": spot_id_for(lat, lon),
|
||||||
|
|
@ -559,6 +677,7 @@ def compute_score_payload_fallback(lat: float, lon: float, at_time: dt.datetime,
|
||||||
"ampel": ampel(score),
|
"ampel": ampel(score),
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"factors": top_reasons,
|
"factors": top_reasons,
|
||||||
|
"explanation": build_explanation(sorted_factors, fallback_spot),
|
||||||
"night_window": {
|
"night_window": {
|
||||||
"start": to_iso(night_start),
|
"start": to_iso(night_start),
|
||||||
"end": to_iso(night_end),
|
"end": to_iso(night_end),
|
||||||
|
|
@ -568,10 +687,11 @@ def compute_score_payload_fallback(lat: float, lon: float, at_time: dt.datetime,
|
||||||
"region": "DE-NW (Pilot: Kreis Mettmann)",
|
"region": "DE-NW (Pilot: Kreis Mettmann)",
|
||||||
"attribution": "Kartendaten: OpenStreetMap-Mitwirkende (ODbL)",
|
"attribution": "Kartendaten: OpenStreetMap-Mitwirkende (ODbL)",
|
||||||
"sources": [],
|
"sources": [],
|
||||||
"health": {"freshest_age_hours": None, "stalest_age_hours": None, "stale_sources": [], "has_data": False},
|
"health": health,
|
||||||
"used_fallback_pois": True,
|
"used_fallback_pois": True,
|
||||||
"degraded": True,
|
"degraded": True,
|
||||||
"degraded_reason": error_code,
|
"degraded_reason": error_code,
|
||||||
|
"quality": quality,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ Entscheidung in <10 Sekunden: Ist ein Spot heute Nacht (22-06) voraussichtlich r
|
||||||
|
|
||||||
## Naechste Schritte
|
## Naechste Schritte
|
||||||
|
|
||||||
1. Reale NRW/Kommunal-URLs in `open_data_sources.json` aktivieren
|
1. Monitoring/Alerting fuer API und Importjobs produktiv aufsetzen
|
||||||
2. Health-Checks + Alerting fuer fehlgeschlagene Importjobs
|
2. "Ruhigere Alternativen im Umkreis" als Quick-Action integrieren
|
||||||
3. Postgres + PostGIS Migration
|
3. Opendata-Quellen in NRW pro Kommune schrittweise erweitern
|
||||||
|
|
||||||
|
Siehe auch: `docs/ROADMAP_30_60_90.md`
|
||||||
|
|
|
||||||
47
docs/ROADMAP_30_60_90.md
Normal file
47
docs/ROADMAP_30_60_90.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# StaySense Roadmap 30/60/90
|
||||||
|
|
||||||
|
Stand: 2026-02-16
|
||||||
|
|
||||||
|
## Priorisierung
|
||||||
|
|
||||||
|
Top-3 mit direktem Nutzwert fuer die WebApp:
|
||||||
|
|
||||||
|
1. Transparente Score-Erklaerung (Faktoren + Spot-Kontext)
|
||||||
|
2. Datenqualitaetsindikator je Score
|
||||||
|
3. Karten-UX: Standort per verschiebbarem Pin auswaehlen
|
||||||
|
|
||||||
|
## Umsetzung Top-3 (abgeschlossen)
|
||||||
|
|
||||||
|
- [x] Transparente Score-Erklaerung
|
||||||
|
- API liefert jetzt `explanation.factors` und `explanation.spot_context`
|
||||||
|
- Frontend zeigt Faktorenliste und Distanz-Kontext (Polizei/Feuerwehr/Krankenhaus)
|
||||||
|
- [x] Datenqualitaetsindikator
|
||||||
|
- API liefert `meta.quality` mit `level`, `label`, `score`, `reasons`
|
||||||
|
- Frontend zeigt Badge `hoch/mittel/niedrig`
|
||||||
|
- [x] Karten-UX verbessert
|
||||||
|
- Marker als verschiebbarer Pin (drag & drop)
|
||||||
|
- Koordinatenfeld wird beim Verschieben sofort aktualisiert
|
||||||
|
|
||||||
|
## 0-30 Tage
|
||||||
|
|
||||||
|
- Monitoring fuer API/Import (Uptime, Error-Rate, Alarmierung)
|
||||||
|
- Score-Engine Observability:
|
||||||
|
- Logging fuer degradierte Scores und Fallback-Nutzung
|
||||||
|
- Admin UX:
|
||||||
|
- Filter fuer Events/Signals nach Zeitraum
|
||||||
|
- Bessere Fehlermeldungen je API-Error
|
||||||
|
|
||||||
|
## 31-60 Tage
|
||||||
|
|
||||||
|
- "Alternativen in der Naehe" (ruhigere Spots im Radius)
|
||||||
|
- Community-Signale erweitern:
|
||||||
|
- optionale Strukturfelder (z. B. Intensitaet, Dauer)
|
||||||
|
- Opendata-Quellen NRW ausbauen:
|
||||||
|
- weitere Kommunen fuer Events/Baustellen
|
||||||
|
|
||||||
|
## 61-90 Tage
|
||||||
|
|
||||||
|
- Persistenter Betrieb auf PostgreSQL (optional PostGIS)
|
||||||
|
- Anomalie-Erkennung gegen Signal-Missbrauch
|
||||||
|
- Exportierbare API-Doku (OpenAPI) und versionierte API (`/v1`)
|
||||||
|
|
||||||
73
src/app.js
73
src/app.js
|
|
@ -24,6 +24,9 @@ const useLocationEl = document.getElementById("use-location");
|
||||||
const scoreEl = document.getElementById("score");
|
const scoreEl = document.getElementById("score");
|
||||||
const ampelEl = document.getElementById("ampel");
|
const ampelEl = document.getElementById("ampel");
|
||||||
const reasonsEl = document.getElementById("reasons");
|
const reasonsEl = document.getElementById("reasons");
|
||||||
|
const factorDetailsEl = document.getElementById("factor-details");
|
||||||
|
const qualityBadgeEl = document.getElementById("quality-badge");
|
||||||
|
const spotContextEl = document.getElementById("spot-context");
|
||||||
const nightWindowEl = document.getElementById("night-window");
|
const nightWindowEl = document.getElementById("night-window");
|
||||||
const networkStatusEl = document.getElementById("network-status");
|
const networkStatusEl = document.getElementById("network-status");
|
||||||
const dataStatusEl = document.getElementById("data-status");
|
const dataStatusEl = document.getElementById("data-status");
|
||||||
|
|
@ -240,6 +243,7 @@ function initializeMap() {
|
||||||
|
|
||||||
function setCoordinates(lat, lon, options = {}) {
|
function setCoordinates(lat, lon, options = {}) {
|
||||||
const zoom = options.zoom || null;
|
const zoom = options.zoom || null;
|
||||||
|
const skipMarkerUpdate = Boolean(options.skipMarkerUpdate);
|
||||||
latEl.value = Number(lat).toFixed(6);
|
latEl.value = Number(lat).toFixed(6);
|
||||||
lonEl.value = Number(lon).toFixed(6);
|
lonEl.value = Number(lon).toFixed(6);
|
||||||
|
|
||||||
|
|
@ -248,16 +252,20 @@ function setCoordinates(lat, lon, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const latLng = [Number(lat), Number(lon)];
|
const latLng = [Number(lat), Number(lon)];
|
||||||
if (!mapMarker) {
|
if (!skipMarkerUpdate) {
|
||||||
mapMarker = L.circleMarker(latLng, {
|
if (!mapMarker) {
|
||||||
radius: 8,
|
mapMarker = L.marker(latLng, {
|
||||||
color: "#006680",
|
draggable: true,
|
||||||
fillColor: "#1ca4c7",
|
icon: L.divIcon({ className: "spot-pin", html: "<span></span>", iconSize: [16, 16], iconAnchor: [8, 8] }),
|
||||||
fillOpacity: 0.8,
|
}).addTo(map);
|
||||||
weight: 2,
|
mapMarker.on("dragend", () => {
|
||||||
}).addTo(map);
|
const pos = mapMarker.getLatLng();
|
||||||
} else {
|
setCoordinates(pos.lat, pos.lng, { fromMap: true, zoom: map.getZoom(), skipMarkerUpdate: true });
|
||||||
mapMarker.setLatLng(latLng);
|
searchStatusEl.textContent = "Position per Pin verschoben.";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mapMarker.setLatLng(latLng);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isFinite(zoom)) {
|
if (Number.isFinite(zoom)) {
|
||||||
|
|
@ -684,6 +692,40 @@ function renderScore(data, fromCache, cacheTime = "") {
|
||||||
reasonsEl.appendChild(li);
|
reasonsEl.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const quality = (data.meta && data.meta.quality) || null;
|
||||||
|
qualityBadgeEl.classList.remove("high", "medium", "low");
|
||||||
|
if (quality && quality.level) {
|
||||||
|
qualityBadgeEl.classList.add(quality.level);
|
||||||
|
qualityBadgeEl.textContent = `${quality.label} (${quality.score})`;
|
||||||
|
} else {
|
||||||
|
qualityBadgeEl.textContent = "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
factorDetailsEl.innerHTML = "";
|
||||||
|
const details = (data.explanation && data.explanation.factors) || data.factors || [];
|
||||||
|
if (!details.length) {
|
||||||
|
const empty = document.createElement("li");
|
||||||
|
empty.textContent = "Keine Faktoren vorhanden.";
|
||||||
|
factorDetailsEl.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
details.slice(0, 8).forEach((item) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = `${item.label} (${Number(item.points).toFixed(1)}) | ${item.source}`;
|
||||||
|
factorDetailsEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotCtx = (data.explanation && data.explanation.spot_context) || null;
|
||||||
|
if (spotCtx) {
|
||||||
|
spotContextEl.textContent =
|
||||||
|
`Spot-Kontext: area ${spotCtx.area_type}, road ${spotCtx.road_type}, ` +
|
||||||
|
`Polizei ${formatMeters(spotCtx.distance_police_m)}, ` +
|
||||||
|
`Feuerwehr ${formatMeters(spotCtx.distance_fire_m)}, ` +
|
||||||
|
`Krankenhaus ${formatMeters(spotCtx.distance_hospital_m)}`;
|
||||||
|
} else {
|
||||||
|
spotContextEl.textContent = "Spot-Kontext: -";
|
||||||
|
}
|
||||||
|
|
||||||
const health = (data.meta && data.meta.health) || {};
|
const health = (data.meta && data.meta.health) || {};
|
||||||
if (health.has_data) {
|
if (health.has_data) {
|
||||||
const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`;
|
const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`;
|
||||||
|
|
@ -706,6 +748,17 @@ function renderScore(data, fromCache, cacheTime = "") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMeters(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (n >= 1000) {
|
||||||
|
return `${(n / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
return `${Math.round(n)} m`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSignal(signalType) {
|
function buildSignal(signalType) {
|
||||||
if (!currentSpot || !currentSpot.spot_id) {
|
if (!currentSpot || !currentSpot.spot_id) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,19 @@
|
||||||
<div class="score" id="score">--</div>
|
<div class="score" id="score">--</div>
|
||||||
<div class="ampel" id="ampel">-</div>
|
<div class="ampel" id="ampel">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="quality-wrap">
|
||||||
|
<span class="quality-label">Datenqualitaet:</span>
|
||||||
|
<span id="quality-badge" class="quality-badge">-</span>
|
||||||
|
</div>
|
||||||
<p id="night-window">Bezug: Heute Nacht 22:00-06:00</p>
|
<p id="night-window">Bezug: Heute Nacht 22:00-06:00</p>
|
||||||
<ul id="reasons" class="reasons">
|
<ul id="reasons" class="reasons">
|
||||||
<li>Noch keine Daten geladen.</li>
|
<li>Noch keine Daten geladen.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<h3 class="subheading">Score-Faktoren</h3>
|
||||||
|
<ul id="factor-details" class="factor-details">
|
||||||
|
<li>Details werden nach der ersten Abfrage angezeigt.</li>
|
||||||
|
</ul>
|
||||||
|
<div id="spot-context" class="spot-context">Spot-Kontext: -</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Community Signal</h3>
|
<h3>Community Signal</h3>
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,21 @@ select {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spot-pin {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-pin span {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
background: #1ca4c7;
|
||||||
|
box-shadow: 0 2px 8px rgba(6, 31, 43, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
@ -263,6 +278,68 @@ select {
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subheading {
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 72px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
color: #fff;
|
||||||
|
background: #5f7280;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge.high {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge.medium {
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badge.low {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.factor-details {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.factor-details li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-context {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.signal-grid {
|
.signal-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue