diff --git a/.eslintrc.json b/.eslintrc.json index 7f37e95..b1b49a0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,7 @@ "@typescript-eslint/no-unused-vars": "warn", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unsafe-declaration-merging": "warn", + "@typescript-eslint/no-namespace": "warn", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/ban-ts-comment": "warn" } diff --git a/package.json b/package.json index 998f385..18268c8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "github-web-widget": "^4.0.0-rc.2", "js-base64": "^3.7.6", "koajax": "^0.9.6", + "lodash.memoize": "^4.1.2", "marked": "^12.0.0", "mobx": "^6.12.0", "web-cell": "^3.0.0-rc.15", @@ -35,6 +36,7 @@ "@parcel/transformer-less": "~2.11.0", "@parcel/transformer-typescript-tsc": "~2.11.0", "@parcel/transformer-webmanifest": "~2.11.0", + "@types/lodash.memoize": "^4.1.9", "@types/node": "^18.19.15", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c6142a..7905cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ dependencies: koajax: specifier: ^0.9.6 version: 0.9.6(typescript@5.3.3) + lodash.memoize: + specifier: ^4.1.2 + version: 4.1.2 marked: specifier: ^12.0.0 version: 12.0.0 @@ -67,6 +70,9 @@ devDependencies: '@parcel/transformer-webmanifest': specifier: ~2.11.0 version: 2.11.0 + '@types/lodash.memoize': + specifier: ^4.1.9 + version: 4.1.9 '@types/node': specifier: ^18.19.15 version: 18.19.15 @@ -2841,6 +2847,16 @@ packages: '@types/node': 18.19.15 dev: true + /@types/lodash.memoize@4.1.9: + resolution: {integrity: sha512-glY1nQuoqX4Ft8Uk+KfJudOD7DQbbEDF6k9XpGncaohW3RW4eSWBlx6AA0fZCrh40tZcQNH4jS/Oc59J6Eq+aw==} + dependencies: + '@types/lodash': 4.14.202 + dev: true + + /@types/lodash@4.14.202: + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} + dev: true + /@types/marked@5.0.2: resolution: {integrity: sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==} dev: true @@ -5242,6 +5258,10 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true diff --git a/source/component/ECharts/Chart.tsx b/source/component/ECharts/Chart.tsx index decec34..ba82c08 100644 --- a/source/component/ECharts/Chart.tsx +++ b/source/component/ECharts/Chart.tsx @@ -1,13 +1,30 @@ +import { JsxProps } from 'dom-renderer'; +import { EChartsOption } from 'echarts'; import { ECharts, init } from 'echarts/core'; +import { ECBasicOption } from 'echarts/types/dist/shared'; import { CustomElement, parseDOM } from 'web-utility'; - -import { ChartType, loadRenderer } from './utility'; +import { + BUILTIN_CHARTS_MAP, + BUITIN_COMPONENTS_MAP, + ChartType, + ECChartOptionName, + ECComponentOptionName, + loadChart, + loadComponent, + loadRenderer, + proxyPrototype +} from './utility'; export class EChartsElement extends HTMLElement implements CustomElement { + #data: EChartsOption = {}; #type: ChartType; #core?: ECharts; #buffer = []; + toJSON() { + return this.#core?.getOption(); + } + set type(value: ChartType) { this.#type = value; this.setAttribute('type', value); @@ -21,13 +38,15 @@ export class EChartsElement extends HTMLElement implements CustomElement { constructor() { super(); + proxyPrototype(this, this.#data, (key, value) => + this.setProperty(key.toString(), value) + ); this.attachShadow({ mode: 'open' }).append( parseDOM('
')[0] ); - this.addEventListener('optionchange', ({ detail }: CustomEvent) => { - if (this.#core) this.#core.setOption(detail); - else this.#buffer.push(detail); - }); + this.addEventListener('optionchange', ({ detail }: CustomEvent) => + this.setOption(detail) + ); } connectedCallback() { @@ -39,16 +58,54 @@ export class EChartsElement extends HTMLElement implements CustomElement { this.#core = init(this.shadowRoot.firstElementChild as HTMLDivElement); - for (const option of this.#buffer) this.#core.setOption(option); + this.setOption(this.#data); + + for (const option of this.#buffer) this.setOption(option); this.#buffer.length = 0; } + + async setOption(data: EChartsOption) { + if (!this.#core) { + this.#buffer.push(data); + return; + } + + for (const key of Object.keys(data)) + if (key in BUITIN_COMPONENTS_MAP) + await loadComponent(key as ECComponentOptionName); + else if (key in BUILTIN_CHARTS_MAP) + await loadChart(key as ECChartOptionName); + + this.#core.setOption(data, false, true); + } + + setProperty(key: string, value: any) { + this.#data[key] = value; + + if (value != null) + switch (typeof value) { + case 'object': + break; + case 'boolean': + if (value) super.setAttribute(key, key + ''); + else super.removeAttribute(key); + break; + default: + super.setAttribute(key, value + ''); + } + else super.removeAttribute(key); + + this.setOption(this.#data); + } } customElements.define('ec-chart', EChartsElement); declare global { - interface HTMLElementTagNameMap { - 'ec-chart': EChartsElement; + namespace JSX { + interface IntrinsicElements { + 'ec-chart': JsxProps & ECBasicOption; + } } } diff --git a/source/component/ECharts/Option.tsx b/source/component/ECharts/Option.tsx index 784f815..13c8ca3 100644 --- a/source/component/ECharts/Option.tsx +++ b/source/component/ECharts/Option.tsx @@ -1,17 +1,23 @@ +import { JsxProps } from 'dom-renderer'; +import { EChartsOption } from 'echarts'; import { CustomElement, toCamelCase, toHyphenCase } from 'web-utility'; import { BUILTIN_CHARTS_MAP, BUITIN_COMPONENTS_MAP, + ECChartOptionName, ECComponentOptionName, - loadChart, - loadComponent + proxyPrototype } from './utility'; export abstract class ECOptionElement extends HTMLElement implements CustomElement { - #data = {}; + #data: EChartsOption = {}; + + toJSON() { + return this.#data; + } get chartTagName() { return toCamelCase(this.tagName.split('-')[1].toLowerCase()); @@ -24,28 +30,12 @@ export abstract class ECOptionElement constructor() { super(); - const prototype = Object.getPrototypeOf(this); - - const prototypeProxy = new Proxy(prototype, { - set: (_, key, value) => { - if (typeof key === 'string') this.setProperty(key, value); - else this[key] = value; - - return true; - }, - get: (prototype, key, receiver) => - key in this.#data - ? this.#data[key] - : Reflect.get(prototype, key, receiver) - }); - - Object.setPrototypeOf(this, prototypeProxy); + proxyPrototype(this, this.#data, (key, value) => + this.setProperty(key.toString(), value) + ); } connectedCallback() { - if (!this.isSeries) - loadComponent(this.chartTagName as ECComponentOptionName); - this.update(); } @@ -65,10 +55,6 @@ export abstract class ECOptionElement } else super.removeAttribute(key); - const { isSeries } = this; - - if (isSeries && key === 'type' && value) return loadChart(value); - if (this.isConnected) this.update(); } @@ -103,7 +89,17 @@ for (const name of Object.keys({ class extends ECOptionElement {} ); +type PickSingle = T extends infer S | (infer S)[] ? S : T; + +type ECOptionElements = { + [K in + | ECComponentOptionName + | ECChartOptionName as `ec-${K}`]: JsxProps & + PickSingle; +}; + declare global { - interface HTMLElementTagNameMap - extends Record<`ec-${ECComponentOptionName}`, ECOptionElement> {} + namespace JSX { + interface IntrinsicElements extends ECOptionElements {} + } } diff --git a/source/component/ECharts/utility.ts b/source/component/ECharts/utility.ts index a1e72da..9d1a8fc 100644 --- a/source/component/ECharts/utility.ts +++ b/source/component/ECharts/utility.ts @@ -1,4 +1,31 @@ import { use } from 'echarts/core'; +import memoize from 'lodash.memoize'; +import { IndexKey } from 'web-utility'; + +export function proxyPrototype( + target: T, + dataStore: Record, + setter?: (key: IndexKey, value: any) => any +) { + const prototype = Object.getPrototypeOf(target); + + const prototypeProxy = new Proxy(prototype, { + set: (_, key, value, receiver) => { + if (key in receiver) Reflect.set(prototype, key, value, receiver); + else dataStore[key] = value; + + setter?.(key, value); + + return true; + }, + get: (prototype, key, receiver) => + key in dataStore + ? dataStore[key] + : Reflect.get(prototype, key, receiver) + }); + + Object.setPrototypeOf(target, prototypeProxy); +} /** * @see {@link https://github.com/apache/echarts/blob/031a908fafaa57e2277b2f720087195925ec38cf/src/model/Global.ts#L83-L111} @@ -35,12 +62,12 @@ export const BUITIN_COMPONENTS_MAP = { export type ECComponentOptionName = keyof typeof BUITIN_COMPONENTS_MAP; -export async function loadComponent(name: ECComponentOptionName) { +export const loadComponent = memoize(async (name: ECComponentOptionName) => { const componentName = BUITIN_COMPONENTS_MAP[name]; const { [componentName]: component } = await import('echarts/components'); use(component); -} +}); /** * @see {@link https://github.com/apache/echarts/blob/031a908fafaa57e2277b2f720087195925ec38cf/src/model/Global.ts#L113-L136} @@ -70,17 +97,19 @@ export const BUILTIN_CHARTS_MAP = { custom: 'CustomChart' } as const; -export async function loadChart(name: keyof typeof BUILTIN_CHARTS_MAP) { +export type ECChartOptionName = keyof typeof BUILTIN_CHARTS_MAP; + +export const loadChart = memoize(async (name: ECChartOptionName) => { const chartName = BUILTIN_CHARTS_MAP[name]; const { [chartName]: chart } = await import('echarts/charts'); use(chart); -} +}); export type ChartType = 'svg' | 'canvas'; -export async function loadRenderer(type: ChartType) { +export const loadRenderer = memoize(async (type: ChartType) => { const { SVGRenderer, CanvasRenderer } = await import('echarts/renderers'); use(type === 'svg' ? SVGRenderer : CanvasRenderer); -} +});