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 { 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() || mode === 'resize' || mode === 'reset'; 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)); } if (dirtyCache) { meta.data.forEach((elem) => delete (elem as any).cache); } this.updateElements(meta.data, 0, meta.data.length, mode); } 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>, AnimationOptions<'choropleth' | 'bubbleMap'> { xAxisID?: string; yAxisID?: string; rAxisID?: string; iAxisID?: string; vAxisID?: string; } export interface IGeoDataPoint { feature: ExtendedFeature; center?: { longitude: number; latitude: number; }; }