chartjs-chart-geo
Version:
Chart.js module for charting maps
303 lines (272 loc) • 7.38 kB
text/typescript
import {
Element,
BarElement,
BarOptions,
VisualElement,
Point,
ChartType,
ScriptableAndArrayOptions,
CommonHoverOptions,
ScriptableContext,
} from 'chart.js';
import { geoContains, GeoPath, GeoProjection } from 'd3-geo';
import type { ProjectionScale } from '../scales';
export interface IGeoFeatureOptions extends Omit<BarOptions, 'borderWidth'>, Record<string, unknown> {
/**
* Width of the border
* @default 0
*/
borderWidth: number;
/**
* background color for the outline
* @default null
*/
outlineBackgroundColor: string | null;
/**
* border color for the outline
* @default defaultColor of Chart.js
*/
outlineBorderColor: string;
/**
* border width for the outline
* @default 0
*/
outlineBorderWidth: number;
/**
* border color for the graticule
* @default #CCCCCC
*/
graticuleBorderColor: string;
/**
* border width for the graticule
* @default 0
*/
graticuleBorderWidth: number;
}
export type Feature = any;
type GeoBounds = ReturnType<GeoPath['bounds']>;
function growGeoBounds(bounds: GeoBounds, amount: number): GeoBounds {
return [
[bounds[0][0] - amount, bounds[0][1] - amount],
[bounds[1][0] + amount, bounds[1][1] + amount],
];
}
export interface IGeoFeatureProps {
x: number;
y: number;
}
export class GeoFeature extends Element<IGeoFeatureProps, IGeoFeatureOptions> implements VisualElement {
/**
* @hidden
*/
cache?:
| {
center?: Point;
bounds?: {
x: number;
y: number;
width: number;
height: number;
x2: number;
y2: number;
};
canvasKey?: string;
canvas?: HTMLCanvasElement;
}
| undefined = undefined;
/**
* @hidden
*/
projectionScale!: ProjectionScale;
/**
* @hidden
*/
feature!: Feature;
/**
* @hidden
*/
center?: { longitude: number; latitude: number };
/**
* @hidden
*/
pixelRatio?: number;
/**
* @hidden
*/
inRange(mouseX: number, mouseY: number): boolean {
const bb = this.getBounds();
const r =
(Number.isNaN(mouseX) || (mouseX >= bb.x && mouseX <= bb.x2)) &&
(Number.isNaN(mouseY) || (mouseY >= bb.y && mouseY <= bb.y2));
const projection = this.projectionScale.geoPath.projection() as unknown as GeoProjection;
if (r && !Number.isNaN(mouseX) && !Number.isNaN(mouseY) && typeof projection.invert === 'function') {
// test for real if within the bounds
const longLat = projection.invert([mouseX, mouseY]);
return longLat != null && geoContains(this.feature, longLat);
}
return r;
}
/**
* @hidden
*/
inXRange(mouseX: number): boolean {
return this.inRange(mouseX, Number.NaN);
}
/**
* @hidden
*/
inYRange(mouseY: number): boolean {
return this.inRange(Number.NaN, mouseY);
}
/**
* @hidden
*/
getCenterPoint(): { x: number; y: number } {
if (this.cache && this.cache.center) {
return this.cache.center;
}
let center: { x: number; y: number };
if (this.center) {
const p = this.projectionScale.projection([this.center.longitude, this.center.latitude])!;
center = {
x: p[0]!,
y: p[1]!,
};
} else {
const centroid = this.projectionScale.geoPath.centroid(this.feature);
center = {
x: centroid[0],
y: centroid[1],
};
}
this.cache = { ...(this.cache || {}), center };
return center;
}
/**
* @hidden
*/
getBounds(): { x: number; y: number; x2: number; y2: number; width: number; height: number } {
if (this.cache && this.cache.bounds) {
return this.cache.bounds;
}
const bb = growGeoBounds(this.projectionScale.geoPath.bounds(this.feature), this.options.borderWidth / 2);
const bounds = {
x: bb[0][0],
x2: bb[1][0],
y: bb[0][1],
y2: bb[1][1],
width: bb[1][0] - bb[0][0],
height: bb[1][1] - bb[0][1],
};
this.cache = { ...(this.cache || {}), bounds };
return bounds;
}
/**
* @hidden
*/
_drawInCache(doc: Document): void {
const bounds = this.getBounds();
if (!Number.isFinite(bounds.x)) {
return;
}
const canvas = this.cache && this.cache.canvas ? this.cache.canvas : doc.createElement('canvas');
const x1 = Math.floor(bounds.x);
const y1 = Math.floor(bounds.y);
const x2 = Math.ceil(bounds.x + bounds.width);
const y2 = Math.ceil(bounds.y + bounds.height);
const pixelRatio = this.pixelRatio || 1;
const width = Math.ceil(Math.max(x2 - x1, 1) * pixelRatio);
const height = Math.ceil(Math.max(y2 - y1, 1) * pixelRatio);
if (width <= 0 || height <= 0) {
return;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(pixelRatio, pixelRatio);
ctx.translate(-x1, -y1);
this._drawImpl(ctx);
ctx.restore();
this.cache = { ...(this.cache || {}), canvas, canvasKey: this._optionsToKey() };
}
}
/**
* @hidden
*/
_optionsToKey(): string {
const { options } = this;
return `${options.backgroundColor};${options.borderColor};${options.borderWidth};${this.pixelRatio}`;
}
/**
* @hidden
*/
_drawImpl(ctx: CanvasRenderingContext2D): void {
const { feature } = this;
const { options } = this;
ctx.beginPath();
this.projectionScale.geoPath.context(ctx)(feature);
if (options.backgroundColor) {
ctx.fillStyle = options.backgroundColor;
ctx.fill();
}
if (options.borderColor) {
ctx.strokeStyle = options.borderColor;
ctx.lineWidth = options.borderWidth as number;
ctx.stroke();
}
}
/**
* @hidden
*/
draw(ctx: CanvasRenderingContext2D): void {
const { feature } = this;
if (!feature) {
return;
}
if ((!this.cache || this.cache.canvasKey !== this._optionsToKey()) && ctx.canvas.ownerDocument != null) {
this._drawInCache(ctx.canvas.ownerDocument);
}
const bounds = this.getBounds();
if (this.cache && this.cache.canvas && this.cache.canvas.width > 0 && this.cache.canvas.height > 0) {
const x1 = Math.floor(bounds.x);
const y1 = Math.floor(bounds.y);
const x2 = Math.ceil(bounds.x + bounds.width);
const y2 = Math.ceil(bounds.y + bounds.height);
const width = x2 - x1;
const height = y2 - y1;
if (width > 0 && height > 0) {
ctx.drawImage(this.cache.canvas, x1, y1, x2 - x1, y2 - y1);
}
} else if (Number.isFinite(bounds.x)) {
ctx.save();
this._drawImpl(ctx);
ctx.restore();
}
}
static id = 'geoFeature';
/**
* @hidden
*/
static defaults = /* #__PURE__ */ {
...BarElement.defaults,
outlineBackgroundColor: null,
outlineBorderWidth: 0,
graticuleBorderColor: '#CCCCCC',
graticuleBorderWidth: 0,
};
/**
* @hidden
*/
static defaultRoutes = /* #__PURE__ */ {
outlineBorderColor: 'borderColor',
...(BarElement.defaultRoutes || {}),
};
}
declare module 'chart.js' {
export interface ElementOptionsByType<TType extends ChartType> {
geoFeature: ScriptableAndArrayOptions<IGeoFeatureOptions & CommonHoverOptions, ScriptableContext<TType>>;
}
}