Public
Edited
May 17, 2023
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
legend_data = FileAttachment("hack_legend-4.csv").csv({ typed: true })
Insert cell
Insert cell
selected_local_data = lea_data
.filter((d) => local_searchbox.includes(d.names))
.concat(california_data)
Insert cell
colorscale = d3.scaleLinear().domain([0, 1]).range(["#f2f2f2", "#000000"])
Insert cell
local_rate = {
if (local_input == "All Offenses") {
return "indexed_total_rate_change";
} else if (local_input == "Violent Offenses") {
return "indexed_violent_rate_change";
} else if (local_input == "Property Offenses") {
return "indexed_property_rate_change";
}
}
Insert cell
local_value = {
if (local_input == "All Offenses") {
return "total_rate";
} else if (local_input == "Violent Offenses") {
return "violent_rate";
} else if (local_input == "Property Offenses") {
return "property_rate";
}
}
Insert cell
california_series = {
if (california_rate_input == "All Offenses") {
return "total";
} else if (california_rate_input == "Violent Offenses") {
return "violent";
} else if (california_rate_input == "Property Offenses") {
return "property";
} else if (california_rate_input == "Homicide") {
return "homicide";
} else if (california_rate_input == "Robbery") {
return "robbery";
} else if (california_rate_input == "Aggravated Assault") {
return "agg_assault";
} else if (california_rate_input == "Rape") {
return "rape";
} else if (california_rate_input == "Burglary") {
return "burg";
} else if (california_rate_input == "Vehicle Theft") {
return "vehicle_theft";
} else if (california_rate_input == "Larceny/Theft") {
return "lt";
}
}
Insert cell
county_static_rate_2021 = {
if (county_rate_input == "All Offenses") {
return "total_rate";
} else if (county_rate_input == "Violent Offenses") {
return "violent_rate";
} else if (county_rate_input == "Property Offenses") {
return "property_rate";
}
}
Insert cell
county_static_rate = {
if (county_rate_input == "All Offenses") {
return "indexed_total_rate_change";
} else if (county_rate_input == "Violent Offenses") {
return "indexed_violent_rate_change";
} else if (county_rate_input == "Property Offenses") {
return "indexed_property_rate_change";
}
}
Insert cell
county_static_rate_historical = {
if (county_rate_input == "All Offenses") {
return "total_clearance_baseline";
} else if (county_rate_input == "Violent Offenses") {
return "violent_baseline";
} else if (county_rate_input == "Property Offenses") {
return "property_baseline";
}
}
Insert cell
Insert cell
county_change_over_time_data = FileAttachment(
"county change over time@1.csv"
).csv({ typed: true })
Insert cell
california_data = FileAttachment("state_level_data@2.csv").csv({ typed: true })
Insert cell
lea_data = FileAttachment("lea_data@4.csv").csv({ typed: true })
Insert cell
ca_data_1985 = FileAttachment("state_data_long.csv").csv({
typed: true
})
Insert cell
ca_spending_data_since_1985 = FileAttachment("ca indexed change since 1985.csv").csv({typed: true})
Insert cell
Insert cell
Insert cell
function searchCheckbox(
data, // An array of possible selectable options
options
) {
options = {
value: [],
optionsCheckboxes: undefined, // use this if you want to pass specific options to the checkboxes or the search
optionsSearch: {
filter: fullSearchFilter // searches in the whole word
},
height: 300,
...options
};

// To remove the label from the options for the checkboxes
function cloneIgnoring(obj, attrToIgnore) {
const { [attrToIgnore]: _, ...rest } = obj;
return rest;
}
data = Array.from(data);
options.value = options.value === undefined ? [] : options.value;
let checkboxes = Inputs.checkbox(
data,
options.optionsCheckboxes || cloneIgnoring(options, "label")
);
const search = Inputs.search(data, options.optionsSearch || options);
const btnAll = html`<button>All</button>`;
const btnNone = html`<button>None</button>`;

let selected = new Map(Array.from(options.value).map((d) => [d, true]));

function countSelected() {
return Array.from(selected.entries()).filter(([k, v]) => v).length;
}

function changeSome(sel, changeTo) {
for (let o of sel) selected.set(o, changeTo);
}

function selectedFromArray(sel) {
changeSome(data, false);
changeSome(sel, true);
}

function selectedToArray() {
return Array.from(selected.entries())
.filter(([k, v]) => v)
.map(([k, v]) => k);
}

// HTML
let output = htl.html`<output>(${countSelected()} of ${
data.length
} selected)</output>`;
const component = htl.html`${
options.label ? htl.html`<label>${options.label}</label>` : ""
}

${output}
<div">
${search}
<div style="margin: 0 5px"> ${btnAll} </div>
<div> ${btnNone} </div>
</div>
<div style="max-height: ${
options.height
}px; overflow: auto">${checkboxes}</div>`;

// Update the display whenever the value changes
Object.defineProperty(component, "value", {
get() {
return selectedToArray();
},
set(v) {
selectedFromArray(v);
}
});

function updateValueFromSelected() {
checkboxes.value = selectedToArray();
output.innerHTML = `(${countSelected()} of ${data.length} selected)`;
component.dispatchEvent(new Event("input", { bubbles: true }));
}

btnAll.addEventListener("click", () => {
changeSome(search.value, true);
updateValueFromSelected();
});
btnNone.addEventListener("click", () => {
changeSome(search.value, false);
console.log("None", selectedToArray());
updateValueFromSelected();
});

component.value = selectedToArray();

search.addEventListener("input", (evt) => {
// Hide all the checkboxes that aren't in the searchbox result
for (let check of checkboxes.querySelectorAll("input")) {
if (search.value.includes(data[+check.value])) {
check.parentElement.style.display = "inline-block";
} else {
check.parentElement.style.display = "none";
}
}
// We don't really need to update when value when searching
// component.dispatchEvent(new Event("input", { bubbles: true }));
});

checkboxes.addEventListener("input", (evt) => {
// avoids duplicated events
evt.stopPropagation();

selectedFromArray(checkboxes.value);
updateValueFromSelected();
});

return component;
}
Insert cell
// https://github.com/observablehq/inputs/blob/main/src/search.js
function fullSearchFilter(query) {
const filters = `${query}`
.split(/\s+/g)
.filter((t) => t)
.map(termFilter);
return (d) => {
if (d == null) return false;
if (typeof d === "object") {
out: for (const filter of filters) {
for (const value of valuesof(d)) {
if (filter.test(value)) {
continue out;
}
}
return false;
}
} else {
for (const filter of filters) {
if (!filter.test(d)) {
return false;
}
}
}
return true;
};
}
Insert cell
function* valuesof(d) {
for (const key in d) {
yield d[key];
}
}
Insert cell
function termFilter(term) {
return new RegExp(`(?:^.*|[^\\p{L}-])${escapeRegExp(term)}`, "iu");
}
Insert cell
function escapeRegExp(text) {
return text.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}
Insert cell
Insert cell
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/multi-line-chart
function LocalLineChart(
data,
{
x = ([x]) => x, // given d in data, returns the (temporal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value

title, // given d in data, returns the title text
defined, // for gaps in data
curve = d3.curveLinear, // method of interpolation between points
marginTop = 20, // top margin, in pixels
marginRight = 130, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleLinear, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
yFormat = percent_format,
yLabel, // a label for the y-axis
zDomain, // array of z-values
color, // = "currentColor", // stroke color of line, as a constant or a function of *z*
strokeLinecap, // stroke line cap of line
strokeLinejoin, // stroke line join of line
strokeWidth = 1.5, // stroke width of line
strokeOpacity, // stroke opacity of line
mixBlendMode = "multiply" // blend mode of lines
} = {}
) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);

const O = d3.map(data, (d) => d);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
const D = d3.map(data, defined);

// Compute default domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = [d3.min(Y), d3.max(Y)];
if (zDomain === undefined) zDomain = Z;
// zDomain = new d3.InternSet(zDomain);

// Omit any data not present in the z-domain.
const I = d3.range(X.length); //filter((i) => zDomain.has(Z[i]));

// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, "0f");
const yAxis = d3.axisLeft(yScale).ticks(height / 60, yFormat);

// Compute titles.
const T =
title === undefined ? Z : title === null ? null : d3.map(data, title);

// Construct a line generator.
const line = d3
.line()
.defined((i) => D[i])
.curve(curve)
.x((i) => xScale(X[i]))
.y((i) => yScale(Y[i]));

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.style("-webkit-tap-highlight-color", "transparent")
.on("pointerenter", pointerentered)
.on("pointermove", pointermoved)
.on("pointerleave", pointerleft)
.on("touchstart", (event) => event.preventDefault());

svg
.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.attr("font-size", "12.5px")
.select(".domain")
.remove();

svg
.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.attr("font-size", "12.5px")
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.append("text")
.attr("font-size", "12.5px")
.attr("x", -marginLeft)
.attr("y", 10)
// .attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel)
);
svg
.append("line")
.attr("x1", marginLeft)
.attr("y1", yScale(0))
.attr("x2", width - marginRight)
.attr("y2", yScale(0))
.attr("stroke-width", 1)
.attr("stroke", "black");

const serie = svg
.append("g")
.selectAll("g")
.data(d3.group(I, (i) => Z[i]))
.join("g");

const path = serie
.append("path")
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", strokeWidth)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-opacity", strokeOpacity)
.style("mix-blend-mode", "multiply")
.attr("d", ([, I]) => line(I));

serie
.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("text-anchor", "start")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.selectAll("text")
.data(([, I]) => I)
.join("text")
// .attr("dy", "0.35em")
.attr("x", (i) => xScale(X[i]))
.attr("y", (i) => yScale(Y[i]))
.attr("dx", 3)
// .call((text) =>
// text
// .filter((_, j, I) => j === I.length - 1)
// .append("tspan")
// .attr("font-weight", "bold")
// .text((i) => Q[i])
// )
.call((text) =>
text
.filter((_, j, I) => j === I.length - 1)
.append("tspan")
.attr("font-size", "12.5")
.text((i) => (I.length < 172 ? ` ${Z[i]}` : null))
)
.call((text) => text.clone(true))
.attr("fill", "none");

const dot = svg.append("g").attr("display", "none");

dot.append("circle").attr("r", 2.5);

function pointermoved(event) {
const [xm, ym] = d3.pointer(event);
const i = d3.least(I, (i) =>
Math.hypot(xScale(X[i]) - xm, yScale(Y[i]) - ym)
); // closest point
path
.style("stroke", ([z]) =>
Z[i] === z ? null : z == "California" ? "#fdc9b4" : "#ddd"
)
.filter(([z]) => Z[i] === z)
.raise();
dot.attr("transform", `translate(${xScale(X[i])},${yScale(Y[i])})`);
if (T) {
const box = dot
.selectAll("path")
.data([,])
.join("path")
.attr("class", "tooltip");
const label = dot
.selectAll("text")
.data([,])
.join("text")
.call((text) =>
text
.selectAll("tspan")
.data(`${T[i]}`.split(/\n/))
.join("tspan")
.attr("x", 0)
.attr("y", (_, i) => `${i * 1.1}em`)
.attr("font-weight", (_, i) => (i ? null : "bold"))
.text((d) => d)
)
.attr("font-family", "sans-serif")
.attr("font-size", 12)
.attr("text-anchor", "middle")
.attr("y", -8);
const { x, y, width: w, height: h } = label.node().getBBox();
box.attr(
"d",
`M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`
);
const hbw = w / 2 + 10;
const hbh = h / 2 + 10;
const limitW = xScale(X[X.length - 1]);
const switchHW = limitW - hbw;
const tooltipOver = `M${-hbw},-5H-5l5,5l5,-5H${hbw}v${-hbh * 2}h-${
2 * hbw
}z`;
const tooltipUnder = `M${-hbw},5H-5l5,-5l5,5H${hbw}v${hbh * 2}h-${
2 * hbw
}z`;
const tooltipLeft = `M-5,${-hbh}V-5l5,5l-5,5V${hbh}H${-hbw * 2}V-${hbh}z`;
const tooltipRight = `M5,${-hbh}V-5l-5,5l5,5V${hbh}H${hbw * 2}V-${hbh}z`;
box.attr(
"d",
xm > switchHW ? tooltipLeft : xm < hbw ? tooltipRight : tooltipUnder
);
const shiftLabelX = xm > switchHW ? -hbw : xm < hbw ? hbw : 0;
const shiftLabelY = xm > switchHW || xm < hbw ? y - h / 2 + 15 : 15 - y;
label.attr("transform", `translate(${shiftLabelX},${shiftLabelY})`);
}
svg.property("value", O[i]).dispatch("input", { bubbles: true });
}

function pointerentered() {
path.style("mix-blend-mode", null).style("stroke", "#ddd");
dot.attr("display", null);
}

function pointerleft() {
path.style("mix-blend-mode", "multiply").style("stroke", null);
dot.attr("display", "none");
svg.node().value = null;
svg.dispatch("input", { bubbles: true });
}

return Object.assign(svg.node(), { value: null });
}
Insert cell
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/multi-line-chart
function caLineChart(
data,
{
x = ([x]) => x, // given d in data, returns the (temporal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value

title, // given d in data, returns the title text
defined, // for gaps in data
curve = d3.curveLinear, // method of interpolation between points
marginTop = 20, // top margin, in pixels
marginRight = 130, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleLinear, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
yFormat = percent_format,
yLabel, // a label for the y-axis
zDomain, // array of z-values
color, // = "currentColor", // stroke color of line, as a constant or a function of *z*
strokeLinecap, // stroke line cap of line
strokeLinejoin, // stroke line join of line
strokeWidth = 1.5, // stroke width of line
strokeOpacity, // stroke opacity of line
mixBlendMode = "multiply" // blend mode of lines
} = {}
) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);

const O = d3.map(data, (d) => d);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
const D = d3.map(data, defined);

// Compute default domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = [d3.min(Y), d3.max(Y)];
if (zDomain === undefined) zDomain = Z;
// zDomain = new d3.InternSet(zDomain);

// Omit any data not present in the z-domain.
const I = d3.range(X.length); //filter((i) => zDomain.has(Z[i]));

// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, "0f");
const yAxis = d3.axisLeft(yScale).ticks(height / 60, yFormat);

// Compute titles.
const T =
title === undefined ? Z : title === null ? null : d3.map(data, title);

// Construct a line generator.
const line = d3
.line()
.defined((i) => D[i])
.curve(curve)
.x((i) => xScale(X[i]))
.y((i) => yScale(Y[i]));

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.style("-webkit-tap-highlight-color", "transparent")
.on("pointerenter", pointerentered)
.on("pointermove", pointermoved)
.on("pointerleave", pointerleft)
.on("touchstart", (event) => event.preventDefault());

svg
.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.attr("font-size", "12.5px")
.select(".domain")
.remove();

svg
.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.attr("font-size", "12.5px")
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.append("text")
.attr("font-size", "12.5px")
.attr("x", -marginLeft)
.attr("y", 10)
// .attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel)
);
svg
.append("line")
.attr("x1", marginLeft)
.attr("y1", yScale(0))
.attr("x2", width - marginRight)
.attr("y2", yScale(0))
.attr("stroke-width", 1)
.attr("stroke", "black");

const serie = svg
.append("g")
.selectAll("g")
.data(d3.group(I, (i) => Z[i]))
.join("g");

const path = serie
.append("path")
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", strokeWidth)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-opacity", strokeOpacity)
.style("mix-blend-mode", "multiply")
.attr("d", ([, I]) => line(I));

serie
.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("text-anchor", "middle")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.selectAll("text")
.data(([, I]) => I)
.join("text")
// .attr("dy", "0.35em")
.attr("x", (i) => xScale(X[i]))
.attr("y", (i) => yScale(Y[i]))
.attr("dx", 17)
// .call((text) =>
// text
// .filter((_, j, I) => j === I.length - 1)
// .append("tspan")
// .attr("font-weight", "bold")
// .text((i) => Q[i])
// )
.call((text) =>
text
.filter((_, j, I) => j === I.length - 1)
.append("tspan")
.attr("font-size", "12.5px")
.text((i) => `${percent_format_plus(Y[i])}`)
)
.call((text) => text.clone(true))
.attr("fill", "none");

const dot = svg.append("g").attr("display", "none");

dot.append("circle").attr("r", 2.5);

function pointermoved(event) {
const [xm, ym] = d3.pointer(event);
const i = d3.least(I, (i) =>
Math.hypot(xScale(X[i]) - xm, yScale(Y[i]) - ym)
); // closest point
path
.style("stroke", ([z]) => (Z[i] === z ? null : "#ddd"))
.filter(([z]) => Z[i] === z)
.raise();
dot.attr("transform", `translate(${xScale(X[i])},${yScale(Y[i])})`);
if (T) {
const box = dot
.selectAll("path")
.data([,])
.join("path")
.attr("class", "tooltip");
const label = dot
.selectAll("text")
.data([,])
.join("text")
.call((text) =>
text
.selectAll("tspan")
.data(`${T[i]}`.split(/\n/))
.join("tspan")
.attr("x", 0)
.attr("y", (_, i) => `${i * 1.1}em`)
.attr("font-weight", (_, i) => (i ? null : "bold"))
.text((d) => d)
)
.attr("font-family", "sans-serif")
.attr("font-size", 12)
.attr("text-anchor", "middle")
.attr("y", -8);
const { x, y, width: w, height: h } = label.node().getBBox();
box.attr(
"d",
`M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`
);
const hbw = w / 2 + 10;
const hbh = h / 2 + 10;
const limitW = xScale(X[X.length - 1]);
const switchHW = limitW - hbw;
const tooltipOver = `M${-hbw},-5H-5l5,5l5,-5H${hbw}v${-hbh * 2}h-${
2 * hbw
}z`;
const tooltipUnder = `M${-hbw},5H-5l5,-5l5,5H${hbw}v${hbh * 2}h-${
2 * hbw
}z`;
const tooltipLeft = `M-5,${-hbh}V-5l5,5l-5,5V${hbh}H${-hbw * 2}V-${hbh}z`;
const tooltipRight = `M5,${-hbh}V-5l-5,5l5,5V${hbh}H${hbw * 2}V-${hbh}z`;
box.attr(
"d",
xm > switchHW ? tooltipLeft : xm < hbw ? tooltipRight : tooltipUnder
);
const shiftLabelX = xm > switchHW ? -hbw : xm < hbw ? hbw : 0;
const shiftLabelY = xm > switchHW || xm < hbw ? y - h / 2 + 15 : 15 - y;
label.attr("transform", `translate(${shiftLabelX},${shiftLabelY})`);
}
svg.property("value", O[i]).dispatch("input", { bubbles: true });
}

function pointerentered() {
path.style("mix-blend-mode", null).style("stroke", "#ddd");
dot.attr("display", null);
}

function pointerleft() {
path.style("mix-blend-mode", "multiply").style("stroke", null);
dot.attr("display", "none");
svg.node().value = null;
svg.dispatch("input", { bubbles: true });
}

return Object.assign(svg.node(), { value: null });
}
Insert cell
Insert cell
import {
addTooltips,
hover,
id_generator
} from "@cyrusobrien/property-crime-rates-by-state-1985-2020"
Insert cell
Plot = tooltipPlugin(await require("@observablehq/plot"))
Insert cell
tooltipPlugin = (Plot) => {
const { plot } = Plot;
Plot.plot = ({ tooltip, ...options }) => addTooltips(plot(options), tooltip);
return Plot;
}
Insert cell
Insert cell
percent_format = d3.format(".0%")
Insert cell
dollar_format = d3.format("$,.0f")
Insert cell
dollar_format_big = (number) => d3.format("$.2s")(number).replace("G", "B")
Insert cell
dollar_format_big(14012305234)
Insert cell
Type JavaScript, then Shift-Enter. Ctrl-space for more options. Arrow ↑/↓ to switch modes.

Insert cell
percent_format_plus = d3.format("+.0%")
Insert cell
html`<style>svg { font-size: 12.5px}`
Insert cell
html`<style>
.tooltip {
fill: #FFFFFF;
opacity: 0.8;
}
</style>`
Insert cell
html`<style>
body {
font-family: sans-serif;
}
</style>`
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more