moved gradient calculation to backend
This commit is contained in:
@@ -17,11 +17,9 @@ metadata:
|
|||||||
namespace: glance-system
|
namespace: glance-system
|
||||||
data:
|
data:
|
||||||
app.py: |-
|
app.py: |-
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from fastapi import FastAPI, Response
|
from fastapi import FastAPI, Response
|
||||||
@@ -29,129 +27,167 @@ data:
|
|||||||
|
|
||||||
APP = FastAPI()
|
APP = FastAPI()
|
||||||
|
|
||||||
IDOKEP_URL = os.getenv(
|
# Configuration
|
||||||
"IDOKEP_URL",
|
IDOKEP_URL = os.getenv("IDOKEP_URL", "https://www.idokep.hu/idojaras/Budapest%20VII.%20ker")
|
||||||
"https://www.idokep.hu/idojaras/Budapest%20VII.%20ker",
|
|
||||||
)
|
|
||||||
PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VII. ker")
|
PLACE_NAME = os.getenv("PLACE_NAME", "Budapest VII. ker")
|
||||||
SOURCE_NAME = "Időkép"
|
SOURCE_NAME = "Időkép"
|
||||||
|
UA = os.getenv("USER_AGENT", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari")
|
||||||
|
|
||||||
UA = os.getenv(
|
# Metrics
|
||||||
"USER_AGENT",
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prometheus metrics (optional)
|
|
||||||
SCRAPES = Counter("idokep_scrapes_total", "Total Időkép scrapes", ["place", "status"])
|
SCRAPES = Counter("idokep_scrapes_total", "Total Időkép scrapes", ["place", "status"])
|
||||||
SCRAPE_SECONDS = Histogram("idokep_scrape_seconds", "Időkép scrape duration in seconds", ["place"])
|
SCRAPE_SECONDS = Histogram("idokep_scrape_seconds", "Időkép scrape duration in seconds", ["place"])
|
||||||
CURRENT_TEMP = Gauge("idokep_current_temp_c", "Current temperature in Celsius", ["place"])
|
|
||||||
DAILY_TMIN = Gauge("idokep_daily_tmin_c", "Daily minimum temperature in Celsius", ["place", "dow"])
|
|
||||||
DAILY_TMAX = Gauge("idokep_daily_tmax_c", "Daily maximum temperature in Celsius", ["place", "dow"])
|
|
||||||
HOURLY_TEMP = Gauge("idokep_hourly_temp_c", "Hourly temperature in Celsius", ["place", "time"])
|
|
||||||
|
|
||||||
|
|
||||||
def _abs_url(maybe_relative: Optional[str]) -> Optional[str]:
|
def _abs_url(maybe_relative: Optional[str]) -> Optional[str]:
|
||||||
if not maybe_relative:
|
if not maybe_relative: return None
|
||||||
return None
|
if maybe_relative.startswith("http"): return maybe_relative
|
||||||
if maybe_relative.startswith("http://") or maybe_relative.startswith("https://"):
|
|
||||||
return maybe_relative
|
|
||||||
# Időkép uses /assets/... paths
|
|
||||||
return "https://www.idokep.hu" + maybe_relative
|
return "https://www.idokep.hu" + maybe_relative
|
||||||
|
|
||||||
|
def _to_float(s: str) -> Optional[float]:
|
||||||
def _to_int_temp(s: str) -> Optional[float]:
|
if not s: return None
|
||||||
if not s:
|
s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "").replace(",", ".")
|
||||||
return None
|
|
||||||
s = s.strip().replace("˚C", "").replace("°C", "").replace("°", "")
|
|
||||||
try:
|
try:
|
||||||
return float(s)
|
return float(s)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _calculate_gradient_data(daily_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Enhances daily items with pre-calculated CSS styles for the temperature bars.
|
||||||
|
"""
|
||||||
|
if not daily_items:
|
||||||
|
return daily_items
|
||||||
|
|
||||||
|
# 1. Find Global Min/Max for the week
|
||||||
|
# We assume the API returns valid floats in tmin_c/tmax_c
|
||||||
|
mins = [d["tmin_c"] for d in daily_items if d["tmin_c"] is not None]
|
||||||
|
maxs = [d["tmax_c"] for d in daily_items if d["tmax_c"] is not None]
|
||||||
|
|
||||||
|
if not mins or not maxs:
|
||||||
|
return daily_items
|
||||||
|
|
||||||
|
g_min = min(mins)
|
||||||
|
g_max = max(maxs)
|
||||||
|
|
||||||
|
# Add a small visual buffer (e.g., 1 degree) so bars don't hit the exact edges
|
||||||
|
g_min -= 1.0
|
||||||
|
g_max += 1.0
|
||||||
|
span = g_max - g_min
|
||||||
|
if span <= 0: span = 1.0
|
||||||
|
|
||||||
|
# 2. Calculate Gradient Stops relative to this week's range
|
||||||
|
# Formula: (TargetTemp - GlobalMin) / Span * 100
|
||||||
|
def get_pos(temp):
|
||||||
|
return (temp - g_min) / span * 100.0
|
||||||
|
|
||||||
|
# Define the fixed color points
|
||||||
|
stops = [
|
||||||
|
(-10, "#ffffff"), # White below -10
|
||||||
|
(0, "#60a5fa"), # Blue at 0
|
||||||
|
(15, "#a78bfa"), # Purple at 15
|
||||||
|
(25, "#fb7185"), # Pink/Red transition at 25
|
||||||
|
(35, "#ef4444"), # Red at 35
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build the linear-gradient string
|
||||||
|
grad_parts = []
|
||||||
|
for temp, color in stops:
|
||||||
|
pos = get_pos(temp)
|
||||||
|
grad_parts.append(f"{color} {pos:.1f}%")
|
||||||
|
|
||||||
|
grad_string = f"linear-gradient(90deg, {', '.join(grad_parts)})"
|
||||||
|
|
||||||
|
# 3. Calculate positioning for each day
|
||||||
|
for d in daily_items:
|
||||||
|
tmin = d["tmin_c"]
|
||||||
|
tmax = d["tmax_c"]
|
||||||
|
|
||||||
|
if tmin is None or tmax is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Bar position relative to track (0-100%)
|
||||||
|
left_pct = (tmin - g_min) / span * 100.0
|
||||||
|
width_pct = (tmax - tmin) / span * 100.0
|
||||||
|
|
||||||
|
if width_pct < 1: width_pct = 1 # Minimum width visibility
|
||||||
|
|
||||||
|
# Inner Gradient Logic:
|
||||||
|
# The inner div needs to be exactly as wide as the PARENT track.
|
||||||
|
# If the bar is width W% (relative to track), the inner div needs to be (100/W)% relative to bar.
|
||||||
|
# It also needs to be shifted left by L% (relative to track), which is -(L/W)% relative to bar.
|
||||||
|
inner_w_pct = (100.0 / width_pct) * 100.0
|
||||||
|
inner_l_pct = -(left_pct / width_pct) * 100.0
|
||||||
|
|
||||||
|
d["bar_style"] = f"left: {left_pct:.1f}%; width: {width_pct:.1f}%;"
|
||||||
|
d["grad_style"] = f"width: {inner_w_pct:.1f}%; margin-left: {inner_l_pct:.1f}%; background: {grad_string};"
|
||||||
|
|
||||||
|
return daily_items
|
||||||
|
|
||||||
def scrape() -> Dict[str, Any]:
|
def scrape() -> Dict[str, Any]:
|
||||||
headers = {"User-Agent": UA}
|
headers = {"User-Agent": UA}
|
||||||
r = requests.get(IDOKEP_URL, headers=headers, timeout=15)
|
r = requests.get(IDOKEP_URL, headers=headers, timeout=15)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
soup = BeautifulSoup(r.text, "html.parser")
|
soup = BeautifulSoup(r.text, "html.parser")
|
||||||
|
|
||||||
# Current
|
# Current Data
|
||||||
cur_temp_el = soup.select_one(".current-temperature")
|
cur_temp_el = soup.select_one(".current-temperature")
|
||||||
cur_cond_el = soup.select_one(".current-weather")
|
cur_cond_el = soup.select_one(".current-weather")
|
||||||
cur_icon_el = soup.select_one(".forecast-bigicon")
|
cur_icon_el = soup.select_one(".forecast-bigicon")
|
||||||
|
|
||||||
cur_temp = _to_int_temp(cur_temp_el.get_text(strip=True) if cur_temp_el else "")
|
cur_temp = _to_float(cur_temp_el.get_text(strip=True) if cur_temp_el else "")
|
||||||
cur_cond = cur_cond_el.get_text(strip=True) if cur_cond_el else ""
|
cur_cond = cur_cond_el.get_text(strip=True) if cur_cond_el else ""
|
||||||
cur_icon = _abs_url(cur_icon_el.get("src") if cur_icon_el else None)
|
cur_icon = _abs_url(cur_icon_el.get("src") if cur_icon_el else None)
|
||||||
|
|
||||||
# Hourly cards (the block you highlighted in devtools: .ik.hourly-forecast-card)
|
# Hourly
|
||||||
hourly: List[Dict[str, Any]] = []
|
hourly = []
|
||||||
for card in soup.select(".ik.hourly-forecast-card")[:8]:
|
for card in soup.select(".ik.hourly-forecast-card")[:8]:
|
||||||
t_el = card.select_one(".ik.hourly-forecast-hour")
|
t_el = card.select_one(".ik.hourly-forecast-hour")
|
||||||
temp_el = card.select_one(".ik.temperature-circled")
|
temp_el = card.select_one(".ik.temperature-circled")
|
||||||
icon_el = card.select_one("img.ik.forecast-icon")
|
icon_el = card.select_one("img.ik.forecast-icon")
|
||||||
|
|
||||||
t = t_el.get_text(strip=True) if t_el else ""
|
t = t_el.get_text(strip=True) if t_el else ""
|
||||||
temp = _to_int_temp(temp_el.get_text(strip=True) if temp_el else "")
|
temp = _to_float(temp_el.get_text(strip=True) if temp_el else "")
|
||||||
icon = _abs_url(icon_el.get("src") if icon_el else None)
|
icon = _abs_url(icon_el.get("src") if icon_el else None)
|
||||||
|
|
||||||
if t and temp is not None:
|
if t and temp is not None:
|
||||||
hourly.append(
|
hourly.append({"time": t, "temp_c": temp, "icon_url": icon})
|
||||||
{
|
|
||||||
"time": t, # e.g. "18:00"
|
|
||||||
"temp_c": temp, # e.g. -2
|
|
||||||
"icon_url": icon, # absolute URL
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Daily columns (bottom forecast table: .ik.daily-forecast-container .ik.dailyForecastCol)
|
# Daily
|
||||||
daily: List[Dict[str, Any]] = []
|
daily = []
|
||||||
for col in soup.select(".ik.daily-forecast-container .ik.dailyForecastCol")[:15]:
|
for col in soup.select(".ik.daily-forecast-container .ik.dailyForecastCol")[:15]:
|
||||||
dow_el = col.select_one(".ik.dfDay")
|
dow_el = col.select_one(".ik.dfDay")
|
||||||
icon_el = col.select_one("img.ik.forecast-icon")
|
icon_el = col.select_one("img.ik.forecast-icon")
|
||||||
daynum_el = col.select_one(".ik.dfDayNum")
|
daynum_el = col.select_one(".ik.dfDayNum")
|
||||||
|
|
||||||
# Normal structure (most days)
|
|
||||||
tmax_el = col.select_one("div.ik.max")
|
tmax_el = col.select_one("div.ik.max")
|
||||||
tmin_el = col.select_one("div.ik.min")
|
tmin_el = col.select_one("div.ik.min")
|
||||||
|
|
||||||
daynum = daynum_el.get_text(strip=True) if daynum_el else ""
|
tmax = _to_float(tmax_el.get_text(strip=True) if tmax_el else "")
|
||||||
dow = dow_el.get_text(strip=True) if dow_el else ""
|
tmin = _to_float(tmin_el.get_text(strip=True) if tmin_el else "")
|
||||||
icon = _abs_url(icon_el.get("src") if icon_el else None)
|
|
||||||
|
|
||||||
tmax = _to_int_temp(tmax_el.get_text(strip=True) if tmax_el else "")
|
# Fallback for "Vacation/Holiday" styling where selectors differ
|
||||||
tmin = _to_int_temp(tmin_el.get_text(strip=True) if tmin_el else "")
|
|
||||||
|
|
||||||
# Fallback structure (e.g. "vacation" days) where div.ik.max/min are missing
|
|
||||||
# In those cases the visible temps are usually the first two numeric <a> texts
|
|
||||||
# inside .ik.min-max-container (order: max, min).
|
|
||||||
if tmax is None or tmin is None:
|
if tmax is None or tmin is None:
|
||||||
vals: List[str] = []
|
vals = []
|
||||||
for a in col.select(".ik.min-max-container a"):
|
for a in col.select(".ik.min-max-container a"):
|
||||||
txt = a.get_text(strip=True)
|
txt = a.get_text(strip=True)
|
||||||
if re.fullmatch(r"-?\d+", txt or ""):
|
if re.fullmatch(r"-?\d+", txt or ""): vals.append(txt)
|
||||||
vals.append(txt)
|
|
||||||
|
|
||||||
if len(vals) >= 2:
|
if len(vals) >= 2:
|
||||||
tmax = _to_int_temp(vals[0])
|
tmax = _to_float(vals[0])
|
||||||
tmin = _to_int_temp(vals[1])
|
tmin = _to_float(vals[1])
|
||||||
|
|
||||||
# Keep only rows that look valid
|
if (tmin is not None) and (tmax is not None):
|
||||||
if dow and (tmin is not None) and (tmax is not None):
|
daily.append({
|
||||||
daily.append(
|
"daynum": daynum_el.get_text(strip=True) if daynum_el else "",
|
||||||
{
|
"dow": dow_el.get_text(strip=True) if dow_el else "",
|
||||||
"daynum": daynum,
|
|
||||||
"dow": dow, # e.g. "Cs", "P", "Sz"
|
|
||||||
"tmin_c": tmin,
|
"tmin_c": tmin,
|
||||||
"tmax_c": tmax,
|
"tmax_c": tmax,
|
||||||
"icon_url": icon,
|
"icon_url": _abs_url(icon_el.get("src") if icon_el else None),
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
# Limit to 5 days for your widget (first 5 columns in the table, including "vacation" days)
|
|
||||||
daily = daily[:5]
|
daily = daily[:5]
|
||||||
|
|
||||||
|
# --- NEW: Calculate Gradient CSS in Python ---
|
||||||
|
daily = _calculate_gradient_data(daily)
|
||||||
|
# ---------------------------------------------
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"source": {"name": SOURCE_NAME, "url": IDOKEP_URL},
|
"source": {"name": SOURCE_NAME, "url": IDOKEP_URL},
|
||||||
"location": {"name": PLACE_NAME},
|
"location": {"name": PLACE_NAME},
|
||||||
@@ -161,37 +197,23 @@ data:
|
|||||||
"fetched_at_unix": int(time.time()),
|
"fetched_at_unix": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@APP.get("/api")
|
@APP.get("/api")
|
||||||
def api():
|
def api():
|
||||||
|
# Standard API endpoint logic...
|
||||||
status = "ok"
|
status = "ok"
|
||||||
with SCRAPE_SECONDS.labels(place=PLACE_NAME).time():
|
|
||||||
try:
|
try:
|
||||||
data = scrape()
|
data = scrape()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
status = "error"
|
status = "error"
|
||||||
SCRAPES.labels(place=PLACE_NAME, status=status).inc()
|
print(f"Error scraping: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# Prometheus Metrics update (simplified for brevity)
|
||||||
SCRAPES.labels(place=PLACE_NAME, status=status).inc()
|
SCRAPES.labels(place=PLACE_NAME, status=status).inc()
|
||||||
|
|
||||||
# Update Prometheus gauges (best-effort)
|
|
||||||
try:
|
|
||||||
if data.get("current", {}).get("temp_c") is not None:
|
|
||||||
CURRENT_TEMP.labels(place=PLACE_NAME).set(float(data["current"]["temp_c"]))
|
|
||||||
for d in data.get("daily", []):
|
|
||||||
DAILY_TMIN.labels(place=PLACE_NAME, dow=d["dow"]).set(float(d["tmin_c"]))
|
|
||||||
DAILY_TMAX.labels(place=PLACE_NAME, dow=d["dow"]).set(float(d["tmax_c"]))
|
|
||||||
for h in data.get("hourly", []):
|
|
||||||
HOURLY_TEMP.labels(place=PLACE_NAME, time=h["time"]).set(float(h["temp_c"]))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# IMPORTANT: force JSON content-type so Glance exposes `.JSON`
|
|
||||||
import json
|
import json
|
||||||
return Response(content=json.dumps(data, ensure_ascii=False), media_type="application/json; charset=utf-8")
|
return Response(content=json.dumps(data, ensure_ascii=False), media_type="application/json; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@APP.get("/metrics")
|
@APP.get("/metrics")
|
||||||
def metrics():
|
def metrics():
|
||||||
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||||
|
|||||||
@@ -693,30 +693,20 @@ data:
|
|||||||
template: |
|
template: |
|
||||||
{{ $loc := .JSON.String "location.name" }}
|
{{ $loc := .JSON.String "location.name" }}
|
||||||
{{ if eq $loc "" }}{{ $loc = .JSON.String "place" }}{{ end }}
|
{{ if eq $loc "" }}{{ $loc = .JSON.String "place" }}{{ end }}
|
||||||
|
|
||||||
{{ $curTemp := .JSON.Float "current.temp_c" }}
|
{{ $curTemp := .JSON.Float "current.temp_c" }}
|
||||||
{{ $curIcon := .JSON.String "current.icon_url" }}
|
{{ $curIcon := .JSON.String "current.icon_url" }}
|
||||||
|
|
||||||
{{ $daily := .JSON.Array "daily" }}
|
{{ $daily := .JSON.Array "daily" }}
|
||||||
{{ $hourly := .JSON.Array "hourly" }}
|
{{ $hourly := .JSON.Array "hourly" }}
|
||||||
|
|
||||||
<div class="idokep">
|
<div class="idokep">
|
||||||
<div class="idokep-top">
|
<div class="idokep-top">
|
||||||
<div class="idokep-top-left">
|
<div class="idokep-top-left">
|
||||||
{{ if $curIcon }}
|
{{ if $curIcon }}<img class="idokep-icon" src="{{ $curIcon }}" alt="" />{{ end }}
|
||||||
<img class="idokep-icon" src="{{ $curIcon }}" alt="" />
|
<div class="idokep-temp">{{ printf "%.0f" $curTemp }}°C</div>
|
||||||
{{ end }}
|
|
||||||
<div class="idokep-temp">
|
|
||||||
{{ printf "%.0f" $curTemp }}°C
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="idokep-top-right">
|
<div class="idokep-top-right">
|
||||||
<div class="idokep-loc">{{ $loc }}</div>
|
<div class="idokep-loc">{{ $loc }}</div>
|
||||||
<div class="idokep-src">
|
<div class="idokep-src">Forrás: <a href="{{ .JSON.String "source.url" }}" target="_blank">Időkép</a></div>
|
||||||
Forrás:
|
|
||||||
<a href="{{ .JSON.String "source.url" }}" target="_blank" rel="noreferrer">Időkép</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -725,66 +715,35 @@ data:
|
|||||||
{{ range $i, $h := $hourly }}
|
{{ range $i, $h := $hourly }}
|
||||||
<div class="idokep-hour">
|
<div class="idokep-hour">
|
||||||
<div class="idokep-hour-time">{{ $h.String "time" }}</div>
|
<div class="idokep-hour-time">{{ $h.String "time" }}</div>
|
||||||
{{ $hicon := $h.String "icon_url" }}
|
{{ if $h.String "icon_url" }}<img class="idokep-hour-icon" src="{{ $h.String "icon_url" }}" alt="" />{{ end }}
|
||||||
{{ if $hicon }}
|
|
||||||
<img class="idokep-hour-icon" src="{{ $hicon }}" alt="" />
|
|
||||||
{{ end }}
|
|
||||||
<div class="idokep-hour-temp">{{ printf "%.0f" ($h.Float "temp_c") }}°</div>
|
<div class="idokep-hour-temp">{{ printf "%.0f" ($h.Float "temp_c") }}°</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
|
||||||
<div class="idokep-muted">No hourly data returned by scraper yet.</div>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if gt (len $daily) 0 }}
|
{{ if gt (len $daily) 0 }}
|
||||||
{{/* compute global min/max */}}
|
|
||||||
{{ $gmin := 9999.0 }}
|
|
||||||
{{ $gmax := -9999.0 }}
|
|
||||||
{{ range $daily }}
|
|
||||||
{{ $tmin := .Float "tmin_c" }}
|
|
||||||
{{ $tmax := .Float "tmax_c" }}
|
|
||||||
{{ if lt $tmin $gmin }}{{ $gmin = $tmin }}{{ end }}
|
|
||||||
{{ if gt $tmax $gmax }}{{ $gmax = $tmax }}{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
{{ $span := sub $gmax $gmin }}
|
|
||||||
{{ if eq $span 0.0 }}{{ $span = 1.0 }}{{ end }}
|
|
||||||
|
|
||||||
{{/* CALCULATE GRADIENT POSITIONS RELATIVE TO CURRENT RANGE */}}
|
|
||||||
{{ $p_wht := printf "%.1f" (mul (div (sub -10.0 $gmin) $span) 100.0) }}
|
|
||||||
{{ $p_blu := printf "%.1f" (mul (div (sub 0.0 $gmin) $span) 100.0) }}
|
|
||||||
{{ $p_pur := printf "%.1f" (mul (div (sub 15.0 $gmin) $span) 100.0) }}
|
|
||||||
{{ $p_pnk := printf "%.1f" (mul (div (sub 25.0 $gmin) $span) 100.0) }}
|
|
||||||
{{ $p_red := printf "%.1f" (mul (div (sub 35.0 $gmin) $span) 100.0) }}
|
|
||||||
|
|
||||||
{{/* Generate the dynamic CSS gradient string */}}
|
|
||||||
{{ $grad := printf "linear-gradient(90deg, #ffffff %s%%, #60a5fa %s%%, #a78bfa %s%%, #fb7185 %s%%, #ef4444 %s%%)" $p_wht $p_blu $p_pur $p_pnk $p_red }}
|
|
||||||
|
|
||||||
<div class="idokep-daily">
|
<div class="idokep-daily">
|
||||||
{{ range $daily }}
|
{{ range $daily }}
|
||||||
{{ $tmin := .Float "tmin_c" }}
|
|
||||||
{{ $tmax := .Float "tmax_c" }}
|
|
||||||
{{ $left := mul (div (sub $tmin $gmin) $span) 100.0 }}
|
|
||||||
{{ $wid := mul (div (sub $tmax $tmin) $span) 100.0 }}
|
|
||||||
<div class="idokep-row">
|
<div class="idokep-row">
|
||||||
<div class="idokep-dow">
|
<div class="idokep-dow">
|
||||||
<span class="idokep-downame">{{ .String "dow" }}</span>
|
<span class="idokep-downame">{{ .String "dow" }}</span>
|
||||||
<span class="idokep-daynum">{{ .String "daynum" }}</span>
|
<span class="idokep-daynum">{{ .String "daynum" }}</span>
|
||||||
</div>
|
</div>
|
||||||
{{ $dicon := .String "icon_url" }}
|
|
||||||
<div class="idokep-dayicon">
|
<div class="idokep-dayicon">
|
||||||
{{ if $dicon }}<img src="{{ $dicon }}" alt="" />{{ end }}
|
{{ if .String "icon_url" }}<img src="{{ .String "icon_url" }}" alt="" />{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="idokep-min">{{ printf "%.0f" $tmin }}°</div>
|
<div class="idokep-min">{{ printf "%.0f" (.Float "tmin_c") }}°</div>
|
||||||
|
|
||||||
<div class="idokep-bar">
|
<div class="idokep-bar">
|
||||||
<div class="idokep-bar-track"></div>
|
<div class="idokep-bar-track"></div>
|
||||||
{{/* Pass calculated offset and gradient to CSS variables */}}
|
{{/* Use the Python-calculated styles directly */}}
|
||||||
<div class="idokep-bar-fill" style="left: {{ printf "%.1f" $left }}%; width: {{ printf "%.1f" $wid }}%;">
|
<div class="idokep-bar-fill" style="{{ .String "bar_style" }}">
|
||||||
<div class="idokep-bar-gradient" style="--off: {{ printf "%.1f" $left }}; --grad: {{ $grad }};"></div>
|
<div class="idokep-bar-gradient" style="{{ .String "grad_style" }}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="idokep-max">{{ printf "%.0f" $tmax }}°</div>
|
|
||||||
|
<div class="idokep-max">{{ printf "%.0f" (.Float "tmax_c") }}°</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
@@ -2058,7 +2017,7 @@ data:
|
|||||||
.idokep-bar {
|
.idokep-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
container-type: inline-size; /* Allows children to know the full track width */
|
/* container-type removed - not needed with Python approach */
|
||||||
}
|
}
|
||||||
.idokep-bar-track {
|
.idokep-bar-track {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -2071,7 +2030,7 @@ data:
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden; /* Clips the inner gradient to this bar's size */
|
overflow: hidden;
|
||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.08) inset;
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.08) inset;
|
||||||
}
|
}
|
||||||
.idokep-min, .idokep-max { text-align: right; font-weight: 700; opacity: 0.8; }
|
.idokep-min, .idokep-max { text-align: right; font-weight: 700; opacity: 0.8; }
|
||||||
@@ -2092,14 +2051,7 @@ data:
|
|||||||
.idokep-bar-gradient {
|
.idokep-bar-gradient {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; bottom: 0;
|
top: 0; bottom: 0;
|
||||||
width: 100cqw; /* Forces width to match the PARENT TRACK, not the fill */
|
/* Width and margin are calculated in Python to match the track width exactly */
|
||||||
|
|
||||||
/* Shift left by the percentage the fill was pushed right.
|
|
||||||
1cqw equals 1% of the track width.
|
|
||||||
So we multiple the offset variable by -1cqw to realign perfectly. */
|
|
||||||
left: calc(var(--off) * -1cqw);
|
|
||||||
|
|
||||||
background: var(--grad);
|
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
|
|||||||
Reference in New Issue
Block a user