tyto | data
HomeLabs
date = new Date()
knmi_raw = {
  const endDate = `${date.getFullYear()}${String(date.getMonth()+1).padStart(2,"0")}${String(date.getDate()).padStart(2,"0")}`;
  const res = await fetch("https://www.daggegevens.knmi.nl/klimatologie/daggegevens", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ start: "19500101", end: endDate, vars: "DDVEC:FXX:TN:TG:TX:RH", stns: "260" })
  });
  const cols = ["STN", "YYYYMMDD", "DDVEC", "FXX", "TN", "TG", "TX", "RH"];
  return (await res.text())
    .split("\n")
    .filter(l => l.trim() && !l.startsWith("#"))
    .map(line => { const v = line.split(","); return Object.fromEntries(cols.map((c, i) => [c, +v[i].trim()])); })
    .filter(d => d.YYYYMMDD > 0);
}
knmi = {
  const currentYear = date.getFullYear();
  return knmi_raw.map(d => {
    const s = String(d.YYYYMMDD);
    const day = new Date(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8));
    const mm = String(day.getMonth() + 1).padStart(2, "0");
    const dd = String(day.getDate()).padStart(2, "0");
    const ref = `${mm}-${dd}` === "02-29" ? null : new Date(`${currentYear}-${mm}-${dd}`);
    let week_start = null;
    if (ref) {
      const dow = ref.getDay();
      week_start = new Date(ref);
      week_start.setDate(ref.getDate() - (dow === 0 ? 6 : dow - 1));
    }
    return { date: day, year: day.getFullYear(), temp_max: d.TX / 10, week_start };
  });
}
knmi_weekly = {
  const map = d3.rollup(
    knmi.filter(d => d.week_start),
    rows => ({
      week_start: rows[0].week_start,
      year: rows[0].year,
      mean_temp_max: Math.round(d3.mean(rows, d => d.temp_max + 1) / 2) * 2 - 1
    }),
    d => `${d.week_start.getTime()}_${d.year}`
  );
  return Array.from(map.values());
}
tiles = {
  const hist = knmi_weekly.filter(d => d.year !== date.getFullYear());
  const countMap = d3.rollup(hist, v => v.length, d => d.week_start.getTime(), d => d.mean_temp_max);
  const result = [];
  for (const [ws_time, tempMap] of countMap) {
    const total = d3.sum(tempMap.values());
    for (const [temp, n] of tempMap)
      result.push({ week_start: new Date(ws_time), mean_temp_max: +temp, n, p: n / total });
  }
  return result;
}
line_current = knmi_weekly
  .filter(d => d.year === date.getFullYear() && d.week_start)
  .sort((a, b) => a.week_start - b.week_start)
point_current = line_current.length > 0 ? line_current[line_current.length - 1] : null
insights = {
  const MONTH_NL = ["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"];
  const current = line_current[line_current.length - 1];
  const hist    = tiles.filter(d => d.week_start.getTime() === current.week_start.getTime());
  const colder  = hist.filter(d => d.mean_temp_max < current.mean_temp_max).reduce((s, d) => s + d.n, 0);
  const total   = hist.reduce((s, d) => s + d.n, 0);
  const pct     = Math.round(colder / total * 100);
  const m       = MONTH_NL[current.week_start.getMonth()];
  const lastDate = d3.max(knmi.filter(d => d.year === date.getFullYear()), d => d.date);
  const fetchedAt = new Date();
  const title = `Week van ${current.week_start.toLocaleDateString("nl-NL", { day: "numeric", month: "long", year: "numeric" })}`;
  const subtitle = pct <= 10 ? `Koud. Uitzonderlijk koud zelfs, voor ${m}.`
                : pct <= 25 ? "Yup... het is koud voor de tijd van het jaar"
                : pct >= 90 ? `Warm. Uitzonderlijk warm zelfs, voor ${m}.`
                : pct >= 75 ? `Het is warm voor ${m}, en dat zie je`
                : `Een doorsnee ${m}, tot nu toe`;
  const note = `Data t/m ${lastDate.toLocaleDateString("nl-NL", { day: "numeric", month: "long" })}  ·  opgehaald ${fetchedAt.toLocaleDateString("nl-NL", { day: "numeric", month: "long" })} om ${fetchedAt.toLocaleTimeString("nl-NL", { hour: "2-digit", minute: "2-digit" })}`;
  return { title, subtitle, note };
}
chart = {
  const tmp = Object.assign(document.createElement("div"), { style: "position:fixed;height:var(--content-padding,0px);visibility:hidden" });
  document.body.appendChild(tmp);
  const padding = tmp.getBoundingClientRect().height;
  document.body.removeChild(tmp);
  const h = window.innerHeight - padding * 2;

  const margin = { top: 120, right: 20, bottom: 35, left: 55 };
  const W = width - margin.left - margin.right;
  const H = h - margin.top - margin.bottom;

  const xScale = d3.scaleTime()
    .domain([
      new Date(d3.min(tiles, d => d.week_start).getTime() - 3 * 864e5),
      new Date(d3.max(tiles, d => d.week_start).getTime() + 10 * 864e5)
    ])
    .range([0, W]);

  const yScale = d3.scaleLinear()
    .domain([d3.min(tiles, d => d.mean_temp_max) - 1, d3.max(tiles, d => d.mean_temp_max) + 1])
    .range([H, 0]);

  const colorScale = d3.scaleSequential()
    .domain([-20, 33])
    .interpolator(d3.piecewise(d3.interpolateRgb, ["#7700cc","#0088cc","#33aa44","#ddaa00","#ff2200"]));

  const tileH   = Math.abs(yScale(0) - yScale(2));
  const lineGen = d3.line().x(d => xScale(d.week_start)).y(d => yScale(d.mean_temp_max));

  const svg = d3.create("svg")
    .attr("width", width)
    .attr("height", h)
    .style("display", "block")
    .style("background", "#111111");
  const g   = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

  [0, 10, 20, 30].forEach(y => {
    g.append("line").attr("x1", 0).attr("x2", W).attr("y1", yScale(y)).attr("y2", yScale(y))
      .attr("stroke", "#111111").attr("stroke-width", 2);
    g.append("line").attr("x1", 0).attr("x2", W).attr("y1", yScale(y)).attr("y2", yScale(y))
      .attr("stroke", "#333333").attr("stroke-width", 1);
  });

  g.selectAll(".tile").data(tiles).join("rect").attr("class", "tile")
    .attr("x",       d => xScale(d.week_start))
    .attr("y",       d => yScale(d.mean_temp_max + 1))
    .attr("width",   d => xScale(new Date(d.week_start.getTime() + 7 * 864e5)) - xScale(d.week_start))
    .attr("height",  tileH)
    .attr("fill",    d => colorScale(d.mean_temp_max))
    .attr("opacity", d => Math.min(1, d.p * 2))
    .attr("stroke", "#111111").attr("stroke-width", 0.6);

  [[6, "#111111"], [1.5, "#ffffff"]].forEach(([sw, stroke]) =>
    g.append("path").datum(line_current)
      .attr("fill", "none").attr("stroke", stroke).attr("stroke-width", sw)
      .attr("stroke-linejoin", "round").attr("pointer-events", "none").attr("d", lineGen)
  );

  if (point_current)
    g.append("circle")
      .attr("cx", xScale(point_current.week_start)).attr("cy", yScale(point_current.mean_temp_max))
      .attr("r", 5).attr("fill", "#ffffff").attr("pointer-events", "none");

  const tooltip    = svg.append("g").attr("display", "none").attr("pointer-events", "none");
  const tooltipBg  = tooltip.append("rect").attr("rx", 3)
    .attr("fill", "#1e1e1e").attr("stroke", "#444").attr("stroke-width", 0.5);
  const tooltipTxt = tooltip.append("text")
    .attr("fill", "#dddddd").attr("font-size", "0.8rem")
    .attr("dx", 6).attr("dy", "1.1em");

  const bisect = d3.bisector(d => d.week_start).left;
  g.append("rect").attr("width", W).attr("height", H).attr("fill", "none").attr("pointer-events", "all")
    .on("pointermove", function(event) {
      const [mx] = d3.pointer(event);
      const x0 = xScale.invert(mx);
      const i  = bisect(line_current, x0, 1);
      const a  = line_current[i - 1], b = line_current[i];
      if (!a) return;
      const d  = b && (x0 - a.week_start > b.week_start - x0) ? b : a;
      const weekEnd = new Date(d.week_start.getTime() + 6 * 864e5);
      const sameMonth = d.week_start.getMonth() === weekEnd.getMonth();
      const range = sameMonth
        ? `${d.week_start.getDate()} → ${weekEnd.toLocaleDateString("nl-NL", { day: "numeric", month: "long" })}`
        : `${d.week_start.toLocaleDateString("nl-NL", { day: "numeric", month: "short" })} → ${weekEnd.toLocaleDateString("nl-NL", { day: "numeric", month: "short" })}`;
      tooltipTxt.text(`${range}  ~${d.mean_temp_max}°C`);
      const bb = tooltipTxt.node().getBBox();
      tooltipBg.attr("x", bb.x - 6).attr("y", bb.y - 4).attr("width", bb.width + 12).attr("height", bb.height + 8);
      tooltip.attr("display", null).attr("transform", `translate(${xScale(d.week_start) + margin.left + 10},${yScale(d.mean_temp_max) + margin.top - 14})`);
    })
    .on("pointerleave", () => tooltip.attr("display", "none"));

  const axText = ax => ax.selectAll("text").attr("fill", "#888888");
  g.append("g").attr("transform", `translate(0,${H})`)
    .call(d3.axisBottom(xScale).ticks(d3.timeMonth.every(1)).tickFormat(d3.timeFormat("%b")).tickSize(0))
    .call(ax => ax.select(".domain").remove())
    .call(ax => axText(ax).attr("font-size", "0.8rem").attr("dy", "1.4em"));

  g.append("g")
    .call(d3.axisLeft(yScale).tickFormat(d => `${d}C°`).tickSize(0))
    .call(ax => ax.select(".domain").remove())
    .call(ax => axText(ax).attr("font-size", "0.75rem"));

  svg.append("foreignObject")
    .attr("x", margin.left).attr("y", 0)
    .attr("width", W).attr("height", margin.top)
    .append("xhtml:div")
    .style("color", "#aaaaaa")
    .html(`<h2 style="margin:0 0 0.15em">${insights.title}</h2>
           <p style="margin:0;opacity:0.7">${insights.subtitle}</p>
           <small style="opacity:0.4">${insights.note}</small>`);

  return svg.node();
}