Avatar

fil

5 public vals
Joined January 27, 2023

Earthquake map 🌏

This val loads earthquake data from USGS, a topojson file for the land shape, and supporting libraries. It then creates a map and save it as a SVG string. The result is cached for a day. Note that we must strive to keep it under val.town’s limit of 100kB, hence the heavy simplification of the land shape. (For a simpler example, see becker barley.)

Web pagehttps://fil-earthquakes.web.val.run/
Observable Plot https://observablehq.com/plot/
linkedomhttps://github.com/WebReflection/linkedom
topojsonhttps://github.com/topojson/topojson
earthquakeshttps://earthquake.usgs.gov
worldhttps://observablehq.com/@visionscarto/world-atlas-topojson
csshttps://milligram.io/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { fetch } from "https://esm.town/v/std/fetch";
import { set } from "https://esm.town/v/std/set?v=11";
let { earthquakes_storage } = await import("https://esm.town/v/fil/earthquakes_storage");
export async function earthquakes(req?) {
const yesterday = new Date(-24 * 3600 * 1000 + +new Date()).toISOString();
if (!(earthquakes_storage?.date > yesterday)) {
const dataUrl = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson";
const worldUrl = "https://cdn.jsdelivr.net/npm/visionscarto-world-atlas@1/world/110m.json";
let [Plot, { document }, topojson, quakes, world] = await Promise.all([
import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.14/+esm"),
import("https://cdn.jsdelivr.net/npm/linkedom@0.15/+esm").then((l) => l.parseHTML("<a>")),
import("https://cdn.jsdelivr.net/npm/topojson@3/+esm"),
fetch(dataUrl).then((r) => r.json()),
fetch(worldUrl).then((r) => r.json()),
]);
world = topojson.presimplify(world, topojson.sphericalTriangleArea);
world = topojson.simplify(world, 0.0001);
const chart = Plot.plot({
document,
projection: { type: "equal-earth", rotate: [-10, 0] },
r: { type: "linear", domain: [0, 5], range: [0, 10] },
marks: [
Plot.geo(topojson.feature(world, world.objects.land)),
Plot.dot(
quakes.features,
Plot.centroid({
r: (d) => d.properties.mag,
fill: "red",
fillOpacity: 0.3,
}),
),
Plot.graticule(),
Plot.sphere(),
],
});
earthquakes_storage = {
date: new Date().toISOString(),
svg: `${chart}`.replaceAll(/(\.\d)\d+/g, "$1"),
};
await set(
"earthquakes_storage",
earthquakes_storage,
);
}
// If invoked through the web endpoint, return a web page.
return req instanceof Request
? new URL(req.url).searchParams.get("svg")
? new Response(
earthquakes_storage.svg.replace(
/^<svg /,
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" version=\"1.1\" ",
),
{ headers: { "Content-Type": "image/svg+xml" } },
)
: new Response(
`<body style="padding: 3em;"><h1>Earthquake map</h1><p>Data updated <abbr title="${earthquakes_storage.date}">daily</abbr>; source: USGS.</p>
${earthquakes_storage.svg}
<small><a href="https://www.val.town/v/fil.earthquakes"><b><i>vt</i></b> source code</a>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">`,
{ headers: { "Content-Type": "text/html" } },
)
: earthquakes_storage;
}

Becker’s Barley trellis

SSR chart with Observable Plot

This chart is rendered server-side by val.town, using Observable Plot, from data loaded from the GitHub API. For a more complete example, see https://www.val.town/v/fil.earthquakes. For information on this chart, see https://observablehq.com/@observablehq/plot-barley-trellis.

chart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export async function beckerBarley() {
const Plot = await import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.14/+esm");
const d3 = await import("https://cdn.jsdelivr.net/npm/d3@7/+esm");
const { document } = await import("https://cdn.jsdelivr.net/npm/linkedom@0.15/+esm").then((
{ parseHTML: p },
) => p(`<a>`));
const barley = await d3.csv(
"https://raw.githubusercontent.com/observablehq/plot/main/test/data/barley.csv",
d3.autoType,
);
const chart = Plot.plot({
document,
marginLeft: 110,
height: 800,
grid: true,
x: { nice: true },
y: { inset: 5 },
color: { type: "categorical" },
facet: { marginRight: 90 },
marks: [
Plot.frame(),
Plot.dot(barley, {
x: "yield",
y: "variety",
fy: "site",
stroke: "year",
sort: { fy: "x", y: "x", reduce: "median", reverse: true },
}),
],
});
return new Response(
`${chart}`.replace(
/^<svg /,
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" version=\"1.1\" ",
),
{ headers: { "Content-Type": "image/svg+xml" } },
);
}

D3 Chord diagram

Example taken from the D3 Gallery, and rendered (server-side) as a static SVG served through the web end point.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { svgServer } from "https://esm.town/v/fil/svgServer";
export async function d3ChordDiagram(req) {
// Import D3 and create a DOM document for server-side-rendering.
const d3 = await import("npm:d3");
const document = await import("https://esm.sh/linkedom@0.15").then((l) =>
l.parseHTML("<a>").document
);
// Data
const data = Object.assign([
[11975, 5871, 8916, 2868],
[1951, 10048, 2060, 6171],
[8010, 16145, 8090, 8045],
[1013, 990, 940, 6907],
], {
names: ["black", "blond", "brown", "red"],
colors: ["#000000", "#ffdd89", "#957244", "#f26223"],
});
// Compute the SVG and return it through the web endpoint
return svgServer(req, chart(data).outerHTML);
//
// ======================================================
//
function chart(data) {
const width = 640;
const height = width;
const outerRadius = Math.min(width, height) * 0.5 - 30;
const innerRadius = outerRadius - 20;
const { names, colors } = data;
const sum = d3.sum(data.flat());
const tickStep = d3.tickStep(0, sum, 100);
const tickStepMajor = d3.tickStep(0, sum, 20);
const formatValue = d3.formatPrefix(",.0", tickStep);
const chord = d3.chord()
.padAngle(20 / innerRadius)
.sortSubgroups(d3.descending);
const arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
const ribbon = d3.ribbon()
.radius(innerRadius);
const svg = d3.select(document.body).append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");
const chords = chord(data);
const group = svg.append("g")
.selectAll()
.data(chords.groups)
.join("g");
group.append("path")
.attr("fill", (d) => colors[d.index])
.attr("d", arc)
.append("title")
.text((d) => `${d.value.toLocaleString("en-US")} ${names[d.index]}`);
const groupTick = group.append("g")
.selectAll()
.data((d) => groupTicks(d, tickStep))
.join("g")
.attr(
"transform",
(d) =>
`rotate(${d.angle * 180 / Math.PI - 90}) translate(${outerRadius},0)`,
);
groupTick.append("line")
.attr("stroke", "currentColor")
.attr("x2", 6);
groupTick
.filter((d) => d.value % tickStepMajor === 0)
.append("text")
.attr("x", 8)
.attr("dy", ".35em")
.attr(
"transform",
(d) => d.angle > Math.PI ? "rotate(180) translate(-16)" : null,
)
.attr("text-anchor", (d) => d.angle > Math.PI ? "end" : null)
.text((d) => formatValue(d.value));
svg.append("g")
.attr("fill-opacity", 0.7)
.selectAll()
.data(chords)
.join("path")
.attr("d", ribbon)
.attr("fill", (d) => colors[d.target.index])
.attr("stroke", "white")
.append("title")
.text((d) =>
`${d.source.value.toLocaleString("en-US")} ${names[d.source.index]} → ${
names[d.target.index]
}${
d.source.index !== d.target.index
? `\n${d.target.value.toLocaleString("en-US")} ${
names[d.target.index]
} → ${names[d.source.index]}`
: ``
}`
);
return svg.node();

Convert a webpage into an ATOM feed, so I can see when there are new activities in the local museum.

feed link: https://fil-musee_angers_activites_feed.web.val.run/

1
2
3
4
5
6
7
import { web2atom } from "https://esm.town/v/fil/web2atom";
export let musee_angers_activites_feed = async () =>
web2atom("https://musees.angers.fr/par-date/index.html", {
title: ".tile h3.title",
link: ".tile a",
});

Passerelle RSS vers BlueSky

Ce script tourne une fois par heure et reposte les news de https://rezo.net/ vers le compte https://bsky.app/profile/rezo.net

Il utilise 3 éléments:

  • l'URL du flux RSS
  • une variable de stockage de l'Ă©tat, qu'il faut crĂ©er initialement comme let storage_rss_rezo = {} et qui sera mise Ă  jour par le script
  • les secrets du compte (username et mot de passe de l'application)

Il appelle @me.bsky_rss_poll qui lit le flux, vérifie avec l'état s'il y a du nouveau, et au besoin nettoie le post, puis l'envoie avec le script @me.post_to_bsky. Sans oublier de mettre à jour l'état pour le prochain run.

C'est un premier jet. Merci à @steve.krouse pour val.town et à @jordan pour ses scripts que j'ai bidouillés ici.

À faire éventuellement: améliorer la logique; poster vers twitter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { set } from "https://esm.town/v/std/set?v=11";
import process from "node:process";
import { storage_rss_rezo } from "https://esm.town/v/fil/storage_rss_rezo";
import { bsky_rss_poll } from "https://esm.town/v/fil/bsky_rss_poll";
export async function cron_rezo_rss2bsky() {
await bsky_rss_poll(
"https://rezo.net/backend/tout",
storage_rss_rezo,
process.env.REZO_BSKY_USERNAME!,
process.env.REZO_BSKY_PASS!,
);
await set("storage_rss_rezo", storage_rss_rezo);
}
Next