Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

temporal contours #2254

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/marks/contour.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {blur2, contours, geoPath, max, min, nice, range, ticks, thresholdSturges} from "d3";
import {blur2, contours, geoPath, max, min, nice, range, ticks, thresholdSturges, scaleUtc} from "d3";
import {createChannels} from "../channel.js";
import {create} from "../context.js";
import {labelof, identity, arrayify, map} from "../options.js";
import {labelof, identity, arrayify, map, isTemporal} from "../options.js";
import {applyPosition} from "../projection.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, styles} from "../style.js";
import {initializer} from "../transforms/basic.js";
Expand Down Expand Up @@ -115,7 +115,8 @@ function contourGeometry({thresholds, interval, ...options}) {
const {pixelSize: k, width: w = Math.round(Math.abs(dx) / k), height: h = Math.round(Math.abs(dy) / k)} = this;
const kx = w / dx;
const ky = h / dy;
const V = channels.value.value;
const temporal = isTemporal(channels.value.value);
const V = temporal && this.blur > 0 ? Float64Array.from(channels.value.value) : channels.value.value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to use coerceNumbers here whenever this.blur > 0, not just in the temporal case? I also wonder if we could provide a hint earlier when the value channel is declared to say that it must include numbers (at least in the this.blur > 0 case).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to make contours work with categorical data, but I feel it would necessitate a completely different approach (we'd have to compute the contours for each of the categories on a boolean raster saying whether a pixel belongs to that category… something like that… with blurring to accomodate the "random-walk" interpolator).

const VV = []; // V per facet

// Interpolate the raster grid, as needed.
Expand Down Expand Up @@ -149,7 +150,7 @@ function contourGeometry({thresholds, interval, ...options}) {
if (this.blur > 0) for (const V of VV) blur2({data: V, width: w, height: h}, this.blur);

// Compute the contour thresholds.
const T = maybeTicks(thresholds, V, ...finiteExtent(VV));
const T = maybeTicks(thresholds, V, ...finiteExtent(VV), temporal);
if (T === null) throw new Error(`unsupported thresholds: ${thresholds}`);

// Compute the (maybe faceted) contours.
Expand Down Expand Up @@ -187,10 +188,11 @@ function contourGeometry({thresholds, interval, ...options}) {
// thresholds across facets. When an interval is used, note that the lowest
// threshold should be below (or equal) to the lowest value, or else some data
// will be missing.
function maybeTicks(thresholds, V, min, max) {
function maybeTicks(thresholds, V, min, max, temporal) {
if (typeof thresholds?.range === "function") return thresholds.range(thresholds.floor(min), max);
if (typeof thresholds === "function") thresholds = thresholds(V, min, max);
if (typeof thresholds !== "number") return arrayify(thresholds);
if (temporal) return scaleUtc().domain([min, max]).nice(thresholds).ticks(thresholds);
Copy link
Member

@mbostock mbostock Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could use utcTickInterval here instead?

if (temporal) {
  thresholds = utcTickInterval(min, max, thresholds);
  return thresholds.range(thresholds.floor(min), max);
}

Maybe we could promote thresholds sooner and then you wouldn’t need to pass the temporal argument down here. Eh, this is fine.

const tz = ticks(...nice(min, max, thresholds), thresholds);
while (tz[tz.length - 1] >= max) tz.pop();
while (tz[1] < min) tz.shift();
Expand Down
72 changes: 72 additions & 0 deletions test/output/walmartsDateContours.html

Large diffs are not rendered by default.

33 changes: 32 additions & 1 deletion test/plots/walmarts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {mesh} from "topojson-client";
import {feature, mesh} from "topojson-client";

export async function walmarts() {
const [walmarts, statemesh] = await Promise.all([
Expand Down Expand Up @@ -36,3 +36,34 @@ export async function walmarts() {
]
});
}

export async function walmartsDateContours() {
const [walmarts, [statemesh, nation]] = await Promise.all([
d3.tsv<any>("data/walmarts.tsv", d3.autoType),
d3.json<any>("data/us-counties-10m.json").then((us) => [
mesh(us, {
type: "GeometryCollection",
geometries: us.objects.states.geometries.filter((d) => d.id !== "02" && d.id !== "15")
}),
feature(us, us.objects.nation)
])
]);
return Plot.plot({
width: 960,
height: 600,
projection: "albers",
color: {legend: true, label: "Mean opening date"},
clip: nation,
marks: [
Plot.contour(walmarts, {
x: "longitude",
y: "latitude",
fill: "date",
interpolate: "random-walk",
blur: 5
}),
Plot.geo(statemesh, {strokeOpacity: 0.25}),
Plot.geo(nation, {strokeWidth: 1.5})
]
});
}