// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import "./polyfills/index";
import charts from "../charts/charts";
import { initialiseStyles } from "../series/colorStyles";
import style from "../../../dist/css/perspective-viewer-d3fc.css";
import { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer";
import type * as psp_types from "@perspective-dev/viewer";
import * as d3 from "d3";
import { Chart, Settings, Type } from "../types";
import * as d3fc_style_1 from "@d3fc/d3fc-chart/src/css.js";
import * as d3fc_style_2 from "@d3fc/d3fc-element/src/css.js";
const DEFAULT_PLUGIN_SETTINGS = {
initial: {
type: "number",
count: 1,
names: [],
},
selectMode: "select",
};
const D3FC_GLOBAL_STYLES = [d3fc_style_1.css, d3fc_style_2.css, style].map(
(x) => {
const sheet = new CSSStyleSheet();
sheet.replaceSync(x);
return sheet;
},
);
const EXCLUDED_SETTINGS = [
"crossValues",
"mainValues",
"realValues",
"splitValues",
"filter",
"data",
"size",
"colorStyles",
"textStyles",
"agg_paths",
"treemaps",
"axisMemo",
"columns_config",
];
async function register_element(plugin_name: string) {
const perspectiveViewerClass = customElements.get("perspective-viewer");
await perspectiveViewerClass.registerPlugin(plugin_name);
}
export function register(...plugin_names: string[]) {
const plugins = new Set(
plugin_names.length > 0
? plugin_names
: charts.map((chart) => chart.plugin.name),
);
charts.forEach((chart) => {
if (plugins.has(chart.plugin.name)) {
const name = chart.plugin.name
.toLowerCase()
.replace(/[ \t\r\n\/]*/g, "");
const plugin_name = `perspective-viewer-d3fc-${name}`;
customElements.define(
plugin_name,
class extends HTMLPerspectiveViewerD3fcPluginElement {
_chart = chart;
static _chart = chart;
},
);
customElements
.whenDefined("perspective-viewer")
.then(() => register_element(plugin_name));
}
});
}
if (!Element.prototype.matches) {
Element.prototype.matches = Element.prototype.msMatchesSelector;
}
class HTMLPerspectiveViewerD3fcPluginElement extends HTMLElement {
_chart: Chart;
static _chart: Chart;
_settings: Settings | null;
render_warning: boolean;
_initialized: boolean;
_container: HTMLElement;
_staged_view;
config;
constructor() {
super();
this._settings = null;
this.render_warning = true;
}
connectedCallback() {
if (!this._initialized) {
this.attachShadow({ mode: "open" });
for (const sheet of D3FC_GLOBAL_STYLES) {
this.shadowRoot.adoptedStyleSheets.push(sheet);
}
this.shadowRoot.innerHTML += `
`;
this._container = this.shadowRoot.querySelector(".chart");
this._initialized = true;
}
}
get name() {
return this._chart.plugin.name;
}
get category() {
return this._chart.plugin.category;
}
get select_mode() {
return this._chart.plugin.selectMode || "select";
}
get group_rollups(): string[] {
return ["flat"];
}
get min_config_columns() {
return (
this._chart.plugin.initial?.count ||
DEFAULT_PLUGIN_SETTINGS.initial.count
);
}
get config_column_names() {
return (
this._chart.plugin.initial?.names ||
DEFAULT_PLUGIN_SETTINGS.initial.names
);
}
// get initial() {
// return chart.plugin.initial
// || DEFAULT_PLUGIN_SETTINGS.initial;
// }
static get max_cells() {
return this._chart.plugin.max_cells || 10_000;
}
static set max_cells(x) {
this._chart.plugin.max_cells = x;
}
static get max_columns() {
return this._chart.plugin.max_columns || 50;
}
static set max_columns(x) {
this._chart.plugin.max_columns = x;
}
get max_cells() {
return this._chart.plugin.max_cells || 10_000;
}
set max_cells(x) {
this._chart.plugin.max_cells = x;
}
get max_columns() {
return this._chart.plugin.max_columns || 50;
}
set max_columns(x) {
this._chart.plugin.max_columns = x;
}
can_render_column_styles(type: Type, group: string) {
return this._chart.can_render_column_styles?.call(this, type, group);
}
column_style_controls(type: Type, group: string) {
return this._chart.column_style_controls?.call(this, type, group);
}
async render() {
var canvas = document.createElement("canvas");
var container: HTMLElement =
this.shadowRoot.querySelector("#container");
canvas.width = container.offsetWidth;
canvas.height = container.offsetHeight;
const context = canvas.getContext("2d");
context.fillStyle =
window
.getComputedStyle(this)
.getPropertyValue("--plugin--background") || "white";
context.fillRect(0, 0, canvas.width, canvas.height);
const text_color = window
.getComputedStyle(this)
.getPropertyValue("color");
const svgs = Array.from(
this.shadowRoot.querySelectorAll(
"svg:not(#dragHandles)",
),
);
for (const svg of svgs.reverse()) {
var img = document.createElement("img");
const defaultOffset = 0;
img.width = svg.parentElement
? svg.parentElement.offsetWidth
: defaultOffset;
img.height = svg.parentElement
? svg.parentElement.offsetHeight
: defaultOffset;
// Pretty sure this is a chrome bug - `drawImage()` call
// without this scales incorrectly.
const new_svg = svg.cloneNode(true) as SVGElement;
if (!new_svg.hasAttribute("viewBox")) {
new_svg.setAttribute(
"viewBox",
`0 0 ${img.width} ${img.height}`,
);
}
new_svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
for (const text of new_svg.querySelectorAll("text")) {
text.setAttribute("fill", text_color);
}
var xml = new XMLSerializer().serializeToString(new_svg);
xml = xml.replace(/[^\x00-\x7F]/g, "");
const done = new Promise((x, y) => {
img.onload = x;
img.onerror = y;
});
try {
img.src = `data:image/svg+xml;base64,${btoa(xml)}`;
await done;
} catch (e) {
const done = new Promise((x, y) => {
img.onload = x;
img.onerror = y;
});
img.src = `data:image/svg+xml;utf8,${xml}`;
await done;
}
context.drawImage(
img,
(svg.parentNode as HTMLElement).offsetLeft,
(svg.parentNode as HTMLElement).offsetTop,
img.width,
img.height,
);
}
const canvases = Array.from(this.shadowRoot.querySelectorAll("canvas"));
for (const canvas of canvases.reverse()) {
context.drawImage(
canvas,
(canvas.parentNode as HTMLElement).offsetLeft,
(canvas.parentNode as HTMLElement).offsetTop,
canvas.width / window.devicePixelRatio,
canvas.height / window.devicePixelRatio,
);
}
return await new Promise(
(x) => canvas.toBlob((blob) => x(blob)),
// @ts-ignore
"image/png", // uhhhh, what is going on here?
);
}
async draw(view, end_col, end_row) {
if (this.offsetParent === null) {
this._staged_view = [view, end_col, end_row];
return;
}
this._staged_view = undefined;
if (this._settings) {
this._settings.axisMemo = [
[Infinity, -Infinity],
[Infinity, -Infinity],
];
}
await this.update(view, end_col, end_row, true);
}
async update(view, end_col, end_row, clear = false) {
if (this.offsetParent === null) {
return;
}
const viewer = this.parentElement as HTMLPerspectiveViewerElement;
let jsonp, metadata;
const leaves_only = this._chart.plugin.name !== "Sunburst";
if (end_col && end_row) {
jsonp = view.to_columns_string({
end_row,
end_col,
leaves_only,
});
} else if (end_col) {
jsonp = view.to_columns_string({
end_col,
leaves_only,
});
} else if (end_row) {
jsonp = view.to_columns_string({
end_row,
leaves_only,
});
} else {
jsonp = view.to_columns_string({ leaves_only });
}
metadata = await Promise.all([
viewer.getViewConfig(),
viewer.getTable().then((table) => table.schema()),
view.expression_schema(false),
view.schema(false),
jsonp,
view.get_config(),
]);
let [
real_config,
table_schema,
expression_schema,
view_schema,
json_string,
config,
] = metadata;
let json2 = JSON.parse(json_string);
const keys = Object.keys(json2);
let json = {
row(ridx) {
const obj: { __ROW_PATH__?: any[] } = {};
for (const name of keys) {
obj[name] = json2[name][ridx];
}
return obj;
},
};
this.config = real_config;
const realValues = this.config.columns;
/**
* Retrieve a tree axis column from the table and
* expression schemas, returning a String type or
* `undefined`.
* @param {String} column a column name
*/
const get_pivot_column_type = function (column) {
let type = table_schema[column];
if (!type) {
type = expression_schema[column];
}
return type;
};
const { columns, group_by, split_by, filter } = config;
const first_col = json2[Object.keys(json2)[0]] || [];
const filtered =
group_by.length > 0
? first_col.reduce(
(acc, _, idx) => {
const col = json.row(idx);
if (
col.__ROW_PATH__ &&
col.__ROW_PATH__.length == group_by.length
) {
acc.agg_paths.push(acc.aggs.slice());
acc.rows.push(col);
} else {
const len = col.__ROW_PATH__.filter(
(x) => x !== undefined,
).length;
acc.aggs[len] = col;
acc.aggs = acc.aggs.slice(0, len + 1);
}
return acc;
},
{ rows: [], aggs: [], agg_paths: [] },
)
: {
rows: first_col.map((_, idx) => json.row(idx)),
};
const dataMap = (col, i) =>
!group_by.length ? { ...col, __ROW_PATH__: [i] } : col;
const mapped = filtered.rows.map(dataMap);
let settings = {
realValues,
crossValues: group_by.map((r) => ({
name: r,
type: get_pivot_column_type(r),
})),
mainValues: columns.map((a) => ({
name: a,
type: view_schema[a],
})),
splitValues: split_by.map((r) => ({
name: r,
type: get_pivot_column_type(r),
})),
filter,
data: mapped,
agg_paths: filtered.agg_paths,
...this.config.plugin_config,
};
const handler = {
set: (obj, prop, value) => {
if (!EXCLUDED_SETTINGS.includes(prop)) {
this._container &&
this._container.dispatchEvent(
new Event("perspective-plugin-update", {
bubbles: true,
composed: true,
}),
);
}
obj[prop] = value;
return true;
},
};
const axisMemo = [
[Infinity, -Infinity],
[Infinity, -Infinity],
];
this._settings = new Proxy(
{
axisMemo,
...this._settings,
...settings,
},
handler,
);
// If only a right-axis Y axis remains, reset the alt
// axis list to default.
if (
this._settings.splitMainValues &&
this._settings.splitMainValues.length >= columns.length
) {
this._settings.splitMainValues = [];
}
initialiseStyles(this._container, this._settings);
if (clear) {
this._container.innerHTML = "";
}
this._draw();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
async clear() {
if (this._container) {
this._container.innerHTML = "";
}
}
_draw() {
if (this.offsetParent !== null) {
const containerDiv = d3.select(this._container);
const name = this._chart.plugin.name
.toLowerCase()
.replace(/[ \t\r\n\/]*/g, "");
const chartClass = `chart ${name}`;
this._settings.size = this._container.getBoundingClientRect();
if (this._settings.data.length > 0) {
this._chart(
containerDiv.attr("class", chartClass),
this._settings,
);
} else {
containerDiv.attr("class", `${chartClass} disabled`);
}
}
}
/**
* TODO we need to `clear()` here unnecessarily due to a bug in the tremap module which
* causes non-cleared redraws duplicate column labels when calculating column name
* resize/repositions - see `treemapLabel.js`.
*/
async resize(_view) {
if (this.offsetParent !== null) {
if (this._settings?.data !== undefined) {
this._draw();
} else {
const [view, end_col, end_row] = this._staged_view;
this._staged_view = undefined;
this.draw(view, end_col, end_row);
}
}
}
async restyle(view) {
let settings = this._settings;
if (settings) {
delete settings["colorStyles"];
delete settings["textStyles"];
if (this.isConnected) {
initialiseStyles(this._container, settings);
this.resize(view);
}
}
}
async delete() {
this._container.innerHTML = "";
}
getContainer() {
return this._container;
}
save() {
const settings = { ...this._settings };
EXCLUDED_SETTINGS.forEach((s) => {
delete settings[s];
});
return settings;
}
restore(settings: Settings, columns_config: psp_types.ColumnConfigValues) {
const new_settings: Partial = {};
for (const name of EXCLUDED_SETTINGS) {
if (this._settings?.[name] !== undefined) {
new_settings[name] = this._settings?.[name];
}
}
this._settings = {
...new_settings,
...settings,
columns_config,
};
}
}