Public
Edited
Aug 10, 2021
28 stars
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
scatter = {
const chartWidth = 600;
const chartHeight = 400;
const margin = 10;
const context = DOM.context2d(chartWidth + margin * 2, chartHeight + margin * 2);
const [minWeight, maxWeight] = extent(athletes, d => d.weight);
const [minHeight, maxHeight] = extent(athletes, d => d.height);
for (const {weight, height, sex} of athletes) {
// const x = (weight - minWeight) / (maxWeight - minWeight) * chartWidth + margin;
// const y = chartHeight - (height - minHeight) / (maxHeight - minHeight) * chartHeight + margin;
// const color = sex === 'male' ? 'orange' : 'steelblue';
const x = linear(weight, [minWeight, maxWeight], [margin, margin + chartWidth]);
const y = linear(height, [minHeight, maxHeight], [chartHeight + margin, margin]);
const color = ordinal(sex, ['male', 'female'], ['orange', 'steelblue']);
context.beginPath();
context.strokeStyle = color;
context.lineWidth = 1.5;
context.arc(x, y, 4, 0, 2 * Math.PI, true);
context.stroke();
context.closePath();
}
function linear(v, domain, range) {
const t = (v - domain[0]) / (domain[1] - domain[0]);
return range[0] * (1 - t) + range[1] * t;
}
function ordinal(v, domain, range) {
return range[domain.indexOf(v)];
}
function extent(data, accessor) {
return [Math.min(...data.map(accessor)), Math.max(...data.map(accessor))];
}
return context.canvas;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
MiniPear = ({
plot(options) {
const {marks, width = 600, height= 400, margin = 10, ...scaleOptions} = options;
const w = width + margin * 2;
const h = height + margin * 2;
const context = DOM.context2d(w, h);
marks.forEach(mark => mark.update(width, height, margin, scaleOptions, context));
syncScales(marks);
marks.forEach(mark => {
mark.composeScales();
mark.plot();
});
return context.canvas;
},
dot: (...args) => new Dot(...args),
barY: (...args) => new BarY(...args),
line: (...args) => new Line(...args),
cell: (...args) => new Cell(...args),
})
Insert cell
function syncScales(marks) {
const scales = marks
.flatMap(d => Object.entries(d.nameScale).map(([, scale]) => scale))
.filter(d => d.options && d.options.domain);

const types = new Set(scales.map(d => d.channelType));

for (const type of types) {
const typeScales = scales.filter(d => d.channelType === type);
const domains = typeScales.reduce((total, cur) => [...total, ...cur.options.domain], []);
const newDomain = getDomain(domains);
typeScales.forEach(d => d.options.domain = newDomain);
}
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
class Mark {
constructor(data = [], encodingOptions = {}) {
this.defaultWidth = 640;
this.defaultHeight = 400;
this.defaultMargin = 10;
this.minHeight = 50;

this.encodingOptions = encodingOptions;
this.data = data;
this.update();
}

update(width, height, margin, scaleOptions, context) {
this.scaleOptions = scaleOptions || {};
this.margin = margin || this.margin || this.defaultMargin;
this.width = width || this.width || this.defaultWidth;
this.height = height || this.height || this.getDefaultHeight();
this.context = context;
this.createScales();
syncScales([this]);
this.composeScales();
}

createScales() {
this.nameScale = {};
for (const [name, channel] of Object.entries(this.channels())) {
const attribute = this.encodingOptions[name];
const encoding = this.createEncoding(attribute);
const scale = this.createScale(encoding, channel, name);
this.nameScale[name] = {
...scale,
encoding,
channelType: channel.options.type
};
}
}

composeScales() {
for (const name of Object.keys(this.nameScale)) {
const { type, options, encoding } = this.nameScale[name];
const Scale = getScaleByType(type);
const scale = Scale(options);
const transform = encoding ? v => scale(encoding(v)) : v => scale(v);
Object.assign(transform, scale);
this[`$${name}`] = transform;
}
}

createEncoding(attribute) {
if (attribute === undefined || attribute === null) return undefined;
if (typeof attribute === 'function') return attribute;
const value = this.data[0] && this.data[0][attribute];
if (value === undefined) {
const encoding = () => attribute;
encoding.identity = true;
return encoding;
}
return d => d[attribute];
}

createScale(encoding, channel, name) {
if (!encoding)
return { type: 'constant', options: { range: [channel.getValue(this)] } };
if (encoding.identity) return { type: 'identity' };

const options = this.scaleOptions[name] || {};
const range = this.chooseRange(encoding, options, channel);
const domain = this.chooseDomain(encoding, { ...options, range });
const [type, defaultOptions = {}] = this.chooseType(
encoding,
{ ...options, range, domain },
channel
);
const newOptions = { ...defaultOptions, ...options, range, domain };
return { type, options: newOptions };
}

chooseType(encoding, options, channel) {
if (options.type) return [options.type];
const { type, range, domain } = options;
return channel.getScaleByTypes(domain[0], range[0]);
}

chooseRange(encoding, options, channel) {
if (options.range) return options.range;
return channel.getRange(this);
}

chooseDomain(encoding, options) {
if (options.domain) return options.domain;
const { domain, range, type } = options;
return getDomain(this.data, encoding, type);
}

getSize() {
return [this.width + this.margin * 2, this.height + this.margin * 2];
}

createCanvas() {
return DOM.context2d(...this.getSize());
}

plot() {
if (!this.context) this.context = this.createCanvas();
for (const [index, d] of this.data.entries()) {
this.drawEach(this.context, d, index, this.data);
}
return this.context.canvas;
}

getDefaultHeight() {
return this.defaultHeight;
}

channels() {
return {
x: new X(),
y: new Y(),
fill: new Color({ value: undefined }),
stroke: new Color({ value: '#00000' }),
lineWidth: new Channel({ value: 1.5, range: [1.5, 1.5] })
};
}

drawEach() {}
}
Insert cell
function getDomain(data, accessor = d => d, type = undefined) {
if (data === undefined) return;
const [d] = data;
if (d === undefined) return [];
if (type === 'quantile') return data.map(accessor);
if (typeof accessor(d) === 'string') return unique(data, accessor);
if (typeof accessor(d) === 'number') return extent(data, accessor);
if (accessor(d) instanceof Date) return extent(data, d => +accessor(d)).map(d => new Date(d));
return [];
}
Insert cell
Insert cell
class Channel {
constructor(options) {
this.options = {type: 'channel', ...options};
}
update(options) {
this.options = {...this.options, ...options};
}
getValue(options) {
const {value} = this.options;
return typeof value === 'function' ? value(options) : value;
}
getRange(options) {
const {range} = this.options;
return typeof range === 'function' ? range(options) : range;
}
getScaleByTypes(d, r) {
if (typeof d === 'number' && typeof r === 'number') {
return this.options.number2number || ['linear'];
} else if (typeof d === 'number' && typeof r === 'string' && isColor(r)) {
return this.options.number2color || ['linear'];
} else if (typeof d === 'string' && typeof r === 'number') {
return this.options.string2number || ['band'];
} else if (typeof d === 'string' && typeof r === 'string') {
return this.options.string2string || ['ordinal'];
} else if (typeof d === 'number' && typeof r === 'string' && !isColor(r)) {
return this.options.number2string || ['ordinal'];
} else if (d instanceof Date && typeof r === 'number') {
return this.options.date2number || ['time'];
} else if (d instanceof Date && typeof r === 'string' && isColor(r)) {
return this.options.date2color || ['time'];
}
return ['identity'];
}
}
Insert cell
class X extends Channel {
constructor(options={}) {
super({
type: 'x',
value: ({width, margin}) => margin + width / 2,
range: ({margin, width}) => [margin, margin + width],
...options,
});
}
}
Insert cell
class Y extends Channel {
constructor(options={}) {
super({
type: 'y',
value: ({height, margin}) => margin + height / 2,
range: ({margin, height}) => [margin + height, margin],
...options,
});
}
}
Insert cell
class Color extends Channel {
constructor(options={}) {
super({
type: 'color',
value: colors[0],
range: colors,
...options
});
}
}
Insert cell
Insert cell
function Linear({ domain, range, interpolate: f } = {}) {
const interpolate =
f || (isColor(range[0]) ? interpolateColor : interpolateValue);
return v => {
const t = (v - domain[0]) / (domain[1] - domain[0]);
const iMax = range.length - 1;
const ti = t * iMax;
const i0 = mod(Math.floor(ti), range.length);
const i1 = mod(i0 + 1, range.length);
return interpolate(mod(ti, 1), range[i0], range[i1]);
};
}
Insert cell
function Ordinal({domain, range} = {}) {
return v => {
const index = domain.indexOf(v);
const i = (index === - 1 ? 0 : index) % range.length;
return range[i];
}
}
Insert cell
function Log({domain, ...rest} = {}) {
const l = Linear({domain: domain.map(Math.log), ...rest});
return v => l(Math.log(v));
}
Insert cell
function Band({domain, range, padding:t=0.1}) {
const discrete = [];
const [r0, r1] = range;
const n = domain.length;
const reverse = r1 < r0;
const min = reverse ? r1 : r0;
const max = reverse ? r0 : r1;
const padding = (max - min) * t / (n * (1 - t) + (n + 1) * t);
const bandWidth = t === 0 ? (max - min) / n : padding * (1 - t) / t;
const step = bandWidth + padding;
for(let i = 0; i < domain.length; i++) {
discrete.push(padding + step * i);
}
if(reverse) discrete.reverse();
const o = Ordinal({domain, range: discrete});
const map = v => o(v);
map.bandWidth = () => bandWidth;
return map;
}
Insert cell
function Identity( {domain, range} = {}) {
return v => v;
}
Insert cell
function Constant({domain = [], range = []} = {}) {
return v => range[0];
}
Insert cell
function Time({domain, range} = {}) {
const l = Linear({domain: domain.map(d => +d), range});
return v => l(+v);
}
Insert cell
function Point({domain, range} = {}) {
const b = Band({domain, range, padding: 1});
return v => b(v);
}
Insert cell
function getScaleByType(type) {
const scales = {
linear: Linear,
ordinal: Ordinal,
identity: Identity,
band: Band,
log: Log,
constant: Constant,
time: Time,
point: Point,
};
return scales[type] || Identity;
}
Insert cell
Insert cell
class Dot extends Mark {
channels() {
const channels = super.channels();
const {x, y} = channels;
const r = new Channel({name:'r', value: 4, range: [4, 10]});
x.update({string2number: ['point']});
y.update({string2number: ['point']});
return {...channels, x, y, r};
}

getDefaultHeight() {
const {encodingOptions, scaleOptions} = this;
if (encodingOptions.y || scaleOptions.y) return this.defaultHeight;
return this.minHeight;
}

drawEach(context, d) {
context.beginPath();
context.strokeStyle = this.$stroke(d);
context.fillStyle = this.$fill(d);
context.lineWidth = this.$lineWidth(d);
context.arc(this.$x(d), this.$y(d), this.$r(d), 0, 2 * Math.PI, true);
if (this.$fill(d)) context.fill();
if (this.$stroke(d)) context.stroke();
context.closePath();
}
}
Insert cell
Inputs.table(athletes);
Insert cell
MiniPear.dot(athletes, { x: 'weight', y: 'height', stroke: 'sex' }).plot();
Insert cell
MiniPear.dot(athletes, { x: 'date_of_birth', y:'sport', stroke: 'date_of_birth' }).plot();
Insert cell
MiniPear.dot(athletes, { x:'weight', stroke: 'steelblue' }).plot();
Insert cell
Insert cell
MiniPear.plot({
margin: 30,
r: {
type: 'log',
range: [2, 20],
interpolate: (t, a, b) => Math.sqrt(a * a * (1 - t) + b * b * t),
},
marks:[MiniPear.dot(nations, { x:'GDP', y: 'LifeExpectancy', r: 'Population', fill:'continent' })],
});
Insert cell
Insert cell
class BarY extends Mark {
constructor(data, encodingOptions) {
if (!encodingOptions.y1) {
encodingOptions.y1 = d => 0;
}
super(data, encodingOptions);
}
channels() {
const channels = super.channels();
const {x, fill} = channels;
x.update({string2number: ['band', {padding: 0.1}]});
fill.update({value: colors[0]});
return {...channels, y1: new Y({value: ({margin, height}) => margin + height}), x}
}
drawEach(context, d) {
const width = this.$x.bandWidth ? this.$x.bandWidth() : 10;
context.beginPath();
context.strokeStyle = this.$stroke(d);
context.fillStyle = this.$fill(d);
context.lineWidth = this.$lineWidth(d);
context.rect(this.$x(d), this.$y(d), width, this.$y1(d) - this.$y(d));
if (this.$fill(d)) context.fill();
if (this.$stroke(d)) context.stroke();
context.closePath();
}
}
Insert cell
Inputs.table(nations);
Insert cell
MiniPear.plot({
x: {
padding: 0.5
},
width,
marks: [MiniPear.barY(nations, { x: 'Country', y: 'GDP'})]
});
Insert cell
Inputs.table(waterfalls);
Insert cell
MiniPear.barY(waterfalls, {
x: 'month',
y: 'end',
y1: 'start',
// fill: 'steelblue',
fill: d => d.month === "Total" ? "Total" : d.profit >= 0 ? "Increase" : "Decrease",
}).plot();
Insert cell
Insert cell
class Line extends Mark {
drawEach(context, d, index, data) {
const x = this.$x(d);
const y = this.$y(d);
if (index === 0) {
context.beginPath();
context.moveTo(x, y);
} else if (index === data.length - 1){
context.lineTo(x, y);
context.strokeStyle = this.$stroke(d);
context.lineWidth = this.$lineWidth(d);
context.stroke();
} else {
context.lineTo(x, y);
}
}
}
Insert cell
Inputs.table(terrorism);
Insert cell
MiniPear.plot({
marks: [
MiniPear.line(terrorism, { x: 'year', y: 'injuries', stroke: colors[0], lineWidth: 2}),
MiniPear.line(terrorism, { x: 'year', y: 'deaths', stroke: colors[1], lineWidth: 2}),
MiniPear.line(terrorism, { x: 'year', y: 'incidents', stroke: colors[2], lineWidth: 2}),
]
});
Insert cell
Insert cell
class Cell extends Mark {
channels() {
const channels = super.channels();
const {x, y, fill} = channels;
const options = {string2number: ['band', {padding: 0.1}]};
x.update(options);
y.update({...options, range: ({margin, height}) => [margin, margin + height]});
fill.update({value: colors[0]});
return {...channels, x, y}
}
drawEach(context, d) {
const width = this.$x.bandWidth ? this.$x.bandWidth() : 10;
const height = this.$y.bandWidth ? this.$y.bandWidth() : 10;
context.beginPath();
context.strokeStyle = this.$stroke(d);
context.fillStyle = this.$fill(d);
context.lineWidth = this.$lineWidth(d);
context.rect(this.$x(d), this.$y(d), width, height);
context.closePath();
if (this.$fill(d)) context.fill();
if (this.$stroke(d)) context.stroke();
}
}
Insert cell
Inputs.table(sports);
Insert cell
MiniPear.plot({
width: 500,
height: 500,
marks:[MiniPear.cell(sports, {
x: d => `${d.index % 10}`,
y: d => `${Math.floor(d.index / 10)}`,
fill: 'type'
})]
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function isColor(string) {
if (typeof string !== 'string') return false;
return string.startsWith('#');
}
Insert cell
function extent(data, accessor) {
const min = Math.min(...data.map(accessor));
const max = Math.max(...data.map(accessor));
return [min, max];
}
Insert cell
function unique(data, accessor) {
const values = data.map(accessor);
return [...new Set(values)];
}
Insert cell
function interpolateValue(t, d0, d1) {
return d0 * (1 - t) + d1 * t;
}
Insert cell
function interpolateColor(t, d0, d1) {
const [r0, g0, b0] = hexToRgb(d0);
const [r1, g1, b1] = hexToRgb(d1);
const r = interpolateValue(t, r0, r1);
const g = interpolateValue(t, g0, g1);
const b = interpolateValue(t, b0, b1);
return rgbToHex(parseInt(r), parseInt(g), parseInt(b));
}
Insert cell
function rgbToHex(r, g, b) {
const hex = ((r << 16) | (g << 8) | b).toString(16);
return "#" + new Array(Math.abs(hex.length - 7)).join("0") + hex;
}
Insert cell
function hexToRgb(hex){
var rgb = [];
for(let i = 1; i < 7; i += 2){
rgb.push(parseInt("0x" + hex.slice(i, i + 2)));
}
return rgb;
}
Insert cell
function mod(x, m) {
return x - Math.floor(x / m) * m;
}
Insert cell
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