chartjs-chart-geo
Version:
Chart.js module for charting maps
271 lines (235 loc) • 7.48 kB
text/typescript
import {
DatasetController,
ChartDataset,
ScriptableAndArrayOptions,
UpdateMode,
Element,
VisualElement,
ScriptableContext,
ChartTypeRegistry,
AnimationOptions,
} from 'chart.js';
import { clipArea, unclipArea, valueOrDefault } from 'chart.js/helpers';
import { geoGraticule, geoGraticule10, ExtendedFeature } from 'd3-geo';
import { ProjectionScale } from '../scales';
import type { GeoFeature, IGeoFeatureOptions } from '../elements';
export const geoDefaults = {
showOutline: false,
showGraticule: false,
clipMap: true,
};
export const geoOverrides = {
scales: {
projection: {
axis: 'x',
type: ProjectionScale.id,
position: 'chartArea',
display: false,
},
},
};
function patchDatasetElementOptions(options: any) {
// patch the options by removing the `outline` or `hoverOutline` option;
// see https://github.com/chartjs/Chart.js/issues/7362
const r: any = { ...options };
Object.keys(options).forEach((key) => {
let targetKey = key;
if (key.startsWith('outline')) {
const sub = key.slice('outline'.length);
targetKey = sub[0].toLowerCase() + sub.slice(1);
} else if (key.startsWith('hoverOutline')) {
targetKey = `hover${key.slice('hoverOutline'.length)}`;
} else {
return;
}
delete r[key];
r[targetKey] = options[key];
});
return r;
}
export class GeoController<
TYPE extends keyof ChartTypeRegistry,
TElement extends Element & VisualElement,
> extends DatasetController<TYPE, TElement, GeoFeature> {
getGeoDataset(): ChartDataset<'choropleth' | 'bubbleMap'> & IGeoControllerDatasetOptions {
return super.getDataset() as unknown as ChartDataset<'choropleth' | 'bubbleMap'> & IGeoControllerDatasetOptions;
}
getGeoOptions(): IGeoChartOptions {
return this.chart.options as unknown as IGeoChartOptions;
}
getProjectionScale(): ProjectionScale {
return this.getScaleForId('projection') as ProjectionScale;
}
linkScales(): void {
const dataset = this.getGeoDataset();
const meta = this.getMeta();
meta.xAxisID = 'projection';
dataset.xAxisID = 'projection';
meta.yAxisID = 'projection';
dataset.yAxisID = 'projection';
meta.xScale = this.getScaleForId('projection');
meta.yScale = this.getScaleForId('projection');
this.getProjectionScale().computeBounds(this.resolveOutline());
}
showOutline(): IGeoChartOptions['showOutline'] {
return valueOrDefault(this.getGeoDataset().showOutline, this.getGeoOptions().showOutline);
}
clipMap(): IGeoChartOptions['clipMap'] {
return valueOrDefault(this.getGeoDataset().clipMap, this.getGeoOptions().clipMap);
}
getGraticule(): IGeoChartOptions['showGraticule'] {
return valueOrDefault(this.getGeoDataset().showGraticule, this.getGeoOptions().showGraticule);
}
update(mode: UpdateMode): void {
super.update(mode);
const meta = this.getMeta();
const scale = this.getProjectionScale();
const dirtyCache = scale.updateBounds();
if (this.showOutline()) {
const elem = meta.dataset!;
if (dirtyCache) {
delete elem.cache;
}
elem.projectionScale = scale;
elem.pixelRatio = this.chart.currentDevicePixelRatio;
if (mode !== 'resize') {
const options = patchDatasetElementOptions(this.resolveDatasetElementOptions(mode));
const properties = {
feature: this.resolveOutline(),
options,
};
this.updateElement(elem, undefined, properties, mode);
if (this.getGraticule()) {
(meta as any).graticule = options;
}
}
} else if (this.getGraticule() && mode !== 'resize') {
(meta as any).graticule = patchDatasetElementOptions(this.resolveDatasetElementOptions(mode));
}
this.updateElements(meta.data, 0, meta.data.length, mode);
if (dirtyCache) {
meta.data.forEach((elem) => delete (elem as any).cache);
}
}
resolveOutline(): any {
const ds = this.getGeoDataset();
const outline = ds.outline || { type: 'Sphere' };
if (Array.isArray(outline)) {
return {
type: 'FeatureCollection',
features: outline,
};
}
return outline;
}
showGraticule(): void {
const g = this.getGraticule();
const options = (this.getMeta() as any).graticule;
if (!g || !options) {
return;
}
const { ctx } = this.chart;
const scale = this.getProjectionScale();
const path = scale.geoPath.context(ctx);
ctx.save();
ctx.beginPath();
if (typeof g === 'boolean') {
if (g) {
path(geoGraticule10());
}
} else {
const geo = geoGraticule();
if (g.stepMajor) {
geo.stepMajor(g.stepMajor as unknown as [number, number]);
}
if (g.stepMinor) {
geo.stepMinor(g.stepMinor as unknown as [number, number]);
}
path(geo());
}
ctx.strokeStyle = options.graticuleBorderColor;
ctx.lineWidth = options.graticuleBorderWidth;
ctx.stroke();
ctx.restore();
}
draw(): void {
const { chart } = this;
const clipMap = this.clipMap();
// enable clipping based on the option
let enabled = false;
if (clipMap === true || clipMap === 'outline' || clipMap === 'outline+graticule') {
enabled = true;
clipArea(chart.ctx, chart.chartArea);
}
if (this.showOutline() && this.getMeta().dataset) {
(this.getMeta().dataset!.draw.call as any)(this.getMeta().dataset!, chart.ctx, chart.chartArea);
}
if (clipMap === true || clipMap === 'graticule' || clipMap === 'outline+graticule') {
if (!enabled) {
clipArea(chart.ctx, chart.chartArea);
}
} else if (enabled) {
enabled = false;
unclipArea(chart.ctx);
}
this.showGraticule();
if (clipMap === true || clipMap === 'items') {
if (!enabled) {
clipArea(chart.ctx, chart.chartArea);
}
} else if (enabled) {
enabled = false;
unclipArea(chart.ctx);
}
this.getMeta().data.forEach((elem) => (elem.draw.call as any)(elem, chart.ctx, chart.chartArea));
if (enabled) {
enabled = false;
unclipArea(chart.ctx);
}
}
}
export interface IGeoChartOptions {
/**
* Outline used to scale and centralize the projection in the chart area.
* By default a sphere is used
* @default { type: 'Sphere" }
*/
outline: any[];
/**
* option to render the outline in the background, see also the outline... styling option
* @default false
*/
showOutline: boolean;
/**
* option to render a graticule in the background, see also the outline... styling option
* @default false
*/
showGraticule:
| boolean
| {
stepMajor: [number, number];
stepMinor: [number, number];
};
/**
* option whether to clip the rendering to the chartArea of the graph
* @default choropleth: true bubbleMap: 'outline+graticule'
*/
clipMap: boolean | 'outline' | 'graticule' | 'outline+graticule' | 'items';
}
export interface IGeoControllerDatasetOptions
extends IGeoChartOptions,
ScriptableAndArrayOptions<IGeoFeatureOptions, ScriptableContext<'choropleth' | 'bubbleMap'>>,
AnimationOptions<'choropleth' | 'bubbleMap'> {
xAxisID?: string;
yAxisID?: string;
rAxisID?: string;
iAxisID?: string;
vAxisID?: string;
}
export interface IGeoDataPoint {
feature: ExtendedFeature;
center?: {
longitude: number;
latitude: number;
};
}