diff --git a/docs/index.0489cf72.js b/docs/index.0489cf72.js new file mode 100644 index 0000000..f44c4dd --- /dev/null +++ b/docs/index.0489cf72.js @@ -0,0 +1,44787 @@ +// modules are defined as an array +// [ module function, map of requires ] +// +// map of requires is short require name -> numeric require +// +// anything defined in a previous bundle is accessed via the +// orig method which is the require for previous bundles + +(function (modules, entry, mainEntry, parcelRequireName, globalName) { + /* eslint-disable no-undef */ + var globalObject = + typeof globalThis !== 'undefined' + ? globalThis + : typeof self !== 'undefined' + ? self + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {}; + /* eslint-enable no-undef */ + + // Save the require from previous bundle to this closure if any + var previousRequire = + typeof globalObject[parcelRequireName] === 'function' && + globalObject[parcelRequireName]; + + var cache = previousRequire.cache || {}; + // Do not use `require` to prevent Webpack from trying to bundle this call + var nodeRequire = + typeof module !== 'undefined' && + typeof module.require === 'function' && + module.require.bind(module); + + function newRequire(name, jumped) { + if (!cache[name]) { + if (!modules[name]) { + // if we cannot find the module within our internal map or + // cache jump to the current global require ie. the last bundle + // that was added to the page. + var currentRequire = + typeof globalObject[parcelRequireName] === 'function' && + globalObject[parcelRequireName]; + if (!jumped && currentRequire) { + return currentRequire(name, true); + } + + // If there are other bundles on this page the require from the + // previous one is saved to 'previousRequire'. Repeat this as + // many times as there are bundles until the module is found or + // we exhaust the require chain. + if (previousRequire) { + return previousRequire(name, true); + } + + // Try the node require function if it exists. + if (nodeRequire && typeof name === 'string') { + return nodeRequire(name); + } + + var err = new Error("Cannot find module '" + name + "'"); + err.code = 'MODULE_NOT_FOUND'; + throw err; + } + + localRequire.resolve = resolve; + localRequire.cache = {}; + + var module = (cache[name] = new newRequire.Module(name)); + + modules[name][0].call( + module.exports, + localRequire, + module, + module.exports, + this + ); + } + + return cache[name].exports; + + function localRequire(x) { + var res = localRequire.resolve(x); + return res === false ? {} : newRequire(res); + } + + function resolve(x) { + var id = modules[name][1][x]; + return id != null ? id : x; + } + } + + function Module(moduleName) { + this.id = moduleName; + this.bundle = newRequire; + this.exports = {}; + } + + newRequire.isParcelRequire = true; + newRequire.Module = Module; + newRequire.modules = modules; + newRequire.cache = cache; + newRequire.parent = previousRequire; + newRequire.register = function (id, exports) { + modules[id] = [ + function (require, module) { + module.exports = exports; + }, + {}, + ]; + }; + + Object.defineProperty(newRequire, 'root', { + get: function () { + return globalObject[parcelRequireName]; + }, + }); + + globalObject[parcelRequireName] = newRequire; + + for (var i = 0; i < entry.length; i++) { + newRequire(entry[i]); + } + + if (mainEntry) { + // Expose entry point to Node, AMD or browser globals + // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js + var mainExports = newRequire(mainEntry); + + // CommonJS + if (typeof exports === 'object' && typeof module !== 'undefined') { + module.exports = mainExports; + + // RequireJS + } else if (typeof define === 'function' && define.amd) { + define(function () { + return mainExports; + }); + + //

Playground

Axes and Grids

Scatterplot

Glyphs

Vectorplot

\ No newline at end of file + + + + + + + Threeplot playground and showcase page + + + + + +

Playground

+
+
+

Axes and Grids

+ +
+
+

Scatterplot

+ +
+
+

Glyphs

+ +
+
+

Vectorplot

+ +
+
+ + diff --git a/package-lock.json b/package-lock.json index 229317a..0cfe079 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "threeplot", - "version": "0.4.3", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "threeplot", - "version": "0.4.3", + "version": "0.6.0", "dependencies": { - "troika-three-text": "0.49" + "troika-three-text": "0.49", + "zod": "3.22.4" }, "devDependencies": { "@types/three": "0.158.2", @@ -5634,13 +5635,13 @@ "dev": true }, "node_modules/vite": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.4.tgz", - "integrity": "sha512-RzAr8LSvM8lmhB4tQ5OPcBhpjOZRZjuxv9zO5UcxeoY2bd3kP3Ticd40Qma9/BqZ8JS96Ll/jeBX9u+LJZrhVg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.9.tgz", + "integrity": "sha512-wVqMd5kp28QWGgfYPDfrj771VyHTJ4UDlCteLH7bJDGDEamaz5hV8IX6h1brSGgnnyf9lI2RnzXq/JmD0c2wwg==", "dev": true, "dependencies": { "esbuild": "^0.19.3", - "postcss": "^8.4.31", + "postcss": "^8.4.32", "rollup": "^4.2.0" }, "bin": { @@ -5930,6 +5931,14 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 095843c..d37300b 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "dist/" ], "dependencies": { - "troika-three-text": "0.49" + "troika-three-text": "0.49", + "zod": "3.22.4" }, "peerDependencies": { "three": "0.158.0" diff --git a/src/axes.config.ts b/src/axes.config.ts index 2b28985..a953c63 100644 --- a/src/axes.config.ts +++ b/src/axes.config.ts @@ -4,9 +4,9 @@ import { LabelProperties } from "./label"; import { BaseConfig } from "./plots/base.config"; export class AxesConfig extends BaseConfig implements AxesParams { - x: AxisConfig | false; - y: AxisConfig | false; - z: AxisConfig | false; + x!: AxisConfig | false; + y!: AxisConfig | false; + z!: AxisConfig | false; constructor(params?: AxesParams) { super(); @@ -20,7 +20,7 @@ export class AxesConfig extends BaseConfig implements AxesParams { } export class AxisConfig extends BaseConfig implements Required { - label: LabelProperties | false; + label!: LabelProperties | false; color: number; width: number; diff --git a/src/axes.ts b/src/axes.ts index 350dadd..416314e 100644 --- a/src/axes.ts +++ b/src/axes.ts @@ -26,7 +26,6 @@ export const NamedAxis = { } as const; export class Axis extends Line2 { - config: AxisConfig; constructor( direction: Vector3, diff --git a/src/frame.ts b/src/frame.ts index 6b69316..c557a63 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -8,8 +8,8 @@ import { AxesParams, GridParams } from "./axes.params"; export class Frame extends Scene { protected scene: Scene; protected renderer: WebGLRenderer; - protected camera: PerspectiveCamera; - protected controls: OrbitControls; + protected camera!: PerspectiveCamera; + protected controls!: OrbitControls; protected observer: ResizeObserver = new ResizeObserver(() => this.onCanvasResize()); protected width: number; protected height: number; diff --git a/src/plots/base.config.ts b/src/plots/base.config.ts index dcac9f1..f17c9de 100644 --- a/src/plots/base.config.ts +++ b/src/plots/base.config.ts @@ -13,6 +13,14 @@ export class BaseConfig { ) { return value === undefined || value === true || isEmpty(value) ? defaultValue! : value!; } + + protected reproduceNTimes(item: Item, n: N): Item[] { + return Array.from({ length: n }, () => item); + } + + protected expandDefaultIfNotExpanded(item: T | T[], n: number): T[] { + return Array.isArray(item) ? item : this.reproduceNTimes(item, n); + } } const isEmpty = (v: T) => { diff --git a/src/plots/scatterplot.config.ts b/src/plots/scatterplot.config.ts index e69de29..d3984dc 100644 --- a/src/plots/scatterplot.config.ts +++ b/src/plots/scatterplot.config.ts @@ -0,0 +1,37 @@ +import { BaseConfig } from "./base.config"; +import { ScatterPlotParams, scatterplotParams } from "./scatterplot.params"; + +export class ScatterPlotConfig extends BaseConfig implements Required { + markerSize: number[]; + markerColor: number[]; + + constructor(nPoints: number, params?: ScatterPlotParams) { + super(); + + const scatterplotParams = this.refineScatterPlotParams(nPoints); + + const result = scatterplotParams.parse(params || {}); + + let { markerSize, markerColor } = result; + + this.markerSize = this.expandDefaultIfNotExpanded(markerSize, nPoints); + this.markerColor = this.expandDefaultIfNotExpanded(markerColor, nPoints); + } + + refineScatterPlotParams(nPoints: number) { + return scatterplotParams.extend({ + markerSize: scatterplotParams.shape.markerSize.refine( + (arg) => arg === undefined || typeof arg === "number" || arg.length === nPoints, + { + message: `markerSize[].length !== points.lengths (${nPoints})`, + } + ), + markerColor: scatterplotParams.shape.markerColor.refine( + (arg) => arg === undefined || typeof arg === "number" || arg.length === nPoints, + { + message: `markerColor[].length !== points.lengths (${nPoints})`, + } + ), + }); + } +} diff --git a/src/plots/scatterplot.params.ts b/src/plots/scatterplot.params.ts new file mode 100644 index 0000000..4706ae0 --- /dev/null +++ b/src/plots/scatterplot.params.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +const markersizeTypeZod = z.number().lte(10).gte(0.01); + +export const scatterplotParams = z.object({ + markerSize: z + .union([markersizeTypeZod, z.array(markersizeTypeZod)]) + .default(0.25), + + markerColor: z + .union([z.number(), z.array(z.number())]) + .default(0x00ff00) +}); + +export type ScatterPlotParams = z.input; diff --git a/src/plots/scatterplot.ts b/src/plots/scatterplot.ts index 9be3b9a..a335bc8 100644 --- a/src/plots/scatterplot.ts +++ b/src/plots/scatterplot.ts @@ -1,13 +1,19 @@ import { Mesh, MeshBasicMaterial, SphereGeometry, Vector3 } from "three"; import { Plot } from "../plot"; +import { ScatterPlotConfig } from "./scatterplot.config"; +import { ScatterPlotParams } from "./scatterplot.params"; export class ScatterPlot extends Plot { - constructor(points: Vector3[], pointRadius = 0.2) { + config!: ScatterPlotConfig; + + constructor(points: Vector3[], params?: ScatterPlotParams) { super(); - this.drawables = points.map((v) => { - const geometry = new SphereGeometry(pointRadius); - const material = new MeshBasicMaterial({ color: 0x00ff00 }); + this.config = new ScatterPlotConfig(points.length, params); + + this.drawables = points.map((v, i) => { + const geometry = new SphereGeometry(this.config.markerSize[i]); + const material = new MeshBasicMaterial({ color: this.config.markerColor[i] }); const obj = new Mesh(geometry, material); obj.position.set(v.x, v.y, v.z); return obj; diff --git a/src/type-magic.ts b/src/type-magic.ts index bf191a3..f695363 100644 --- a/src/type-magic.ts +++ b/src/type-magic.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + // https://stackoverflow.com/questions/55479658/how-to-create-a-type-excluding-instance-methods-from-a-class-in-typescript export type DataPropertyNames = { [K in keyof T]: T[K] extends Function ? never : K; @@ -8,3 +10,33 @@ type DataPropertiesOnly = { }; export type DTO = DataPropertiesOnly; + +// https://github.com/colinhacks/zod/discussions/1953 +export function getDefaults(schema: z.AnyZodObject | z.ZodEffects): z.infer { + // Check if it's a ZodEffect + if (schema instanceof z.ZodEffects) { + // Check if it's a recursive ZodEffect + if (schema.innerType() instanceof z.ZodEffects) return getDefaults(schema.innerType()); + // return schema inner shape as a fresh zodObject + return getDefaults(z.ZodObject.create(schema.innerType().shape)); + } + + function getDefaultValue(schema: any) { + if (schema instanceof z.ZodDefault) return schema._def.defaultValue(); + // return an empty array if it is + if (schema instanceof z.ZodArray) return []; + // return an empty string if it is + if (schema instanceof z.ZodString) return ""; + // return an content of object recursivly + if (schema instanceof z.ZodObject) return getDefaults(schema); + + if (!("innerType" in schema._def)) return undefined; + return getDefaultValue(schema._def.innerType); + } + + return Object.fromEntries( + Object.entries(schema.shape).map(([key, value]) => { + return [key, getDefaultValue(value)]; + }) + ); +} diff --git a/test/e2e/index.ts b/test/e2e/index.ts index c4e58f7..1530157 100644 --- a/test/e2e/index.ts +++ b/test/e2e/index.ts @@ -6,12 +6,13 @@ const canvas2 = document.getElementById("canvas2") as HTMLCanvasElement; const canvas3 = document.getElementById("canvas3") as HTMLCanvasElement; const canvas4 = document.getElementById("canvas4") as HTMLCanvasElement; -const points = getRandomPoints(200); -const scatterPlot = new ScatterPlot(points); - new Frame(canvas1, 10, { x: false, y: { width: 0.025, label: { text: Greek.betaSymbol } } }, { yz: false }); const frame2 = new Frame(canvas2, 10); +const points = getRandomPoints(200); +const scatterPlot = new ScatterPlot(points, { + markerColor: Array.from(Array(200).keys()).map((n) => n * 100000), +}); frame2.addPlot(scatterPlot); const frame3 = new Frame(canvas3, 10); diff --git a/test/unit/scatterplot.config.test.ts b/test/unit/scatterplot.config.test.ts new file mode 100644 index 0000000..4e73c0b --- /dev/null +++ b/test/unit/scatterplot.config.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "vitest"; +import { ScatterPlotConfig } from "../../src/plots/scatterplot.config"; +import { ScatterPlotParams } from "../../src/plots/scatterplot.params"; +import { scatterplotParams } from "../../dist/plots/scatterplot.params"; +import { getDefaults } from "../../src/type-magic"; + +test("config correctly instantiates with markersize = 0.01", () => { + const params: ScatterPlotParams = { markerSize: 0.01, markerColor: 0x000000 }; + const config = new ScatterPlotConfig(10, params); + expect(config).toBeInstanceOf(ScatterPlotConfig); +}); + +test("config correctly instantiates with 10 > markersize > 0.01", () => { + const params: ScatterPlotParams = { markerSize: 1, markerColor: 0x000000 }; + const config = new ScatterPlotConfig(10, params); + expect(config).toBeInstanceOf(ScatterPlotConfig); +}); + +test("config throws if markersize < 0.01", () => { + const params: ScatterPlotParams = { markerSize: 0.0099, markerColor: 0x000000 }; + expect(() => new ScatterPlotConfig(10, params)).toThrowError(Error); +}); + +test("config throws if markersize > 10", () => { + const params: ScatterPlotParams = { markerSize: 10.1, markerColor: 0x000000 }; + expect(() => new ScatterPlotConfig(10, params)).toThrowError(Error); +}); + +test("config correctly instantiates with markersize.length === nPoints", () => { + const params: ScatterPlotParams = { markerSize: [1, 1, 1, 1], markerColor: 0x000000 }; + const config = new ScatterPlotConfig(4, params); + expect(config).toBeInstanceOf(ScatterPlotConfig); +}); + +test("config correctly instantiates with markercolor.length === nPoints", () => { + const params: ScatterPlotParams = { markerSize: 1, markerColor: [1, 1, 1, 1] }; + const config = new ScatterPlotConfig(4, params); + expect(config).toBeInstanceOf(ScatterPlotConfig); +}); + +test("config throws if markersize.length !== nPoints", () => { + const params: ScatterPlotParams = { markerSize: [1], markerColor: 0x000000 }; + expect(() => new ScatterPlotConfig(10, params)).toThrowError(Error); +}); + +test("config throws if markercolor.length !== nPoints", () => { + const params: ScatterPlotParams = { markerSize: 1, markerColor: [1] }; + expect(() => new ScatterPlotConfig(10, params)).toThrowError(Error); +}); + +test("config falls-back to defaults when params are unspecified", () => { + const nPoints = 10; + const config = new ScatterPlotConfig(nPoints); + expect(config.markerSize).toHaveLength(nPoints); + expect(config.markerSize.at(0)).toEqual(0.01); + expect(config.markerColor).toHaveLength(nPoints); + expect(config.markerColor.at(0)).toEqual(0x00ff00); +}); + +test("config falls-back to defaults when params are partially specified", () => { + const nPoints = 10; + const config = new ScatterPlotConfig(nPoints, { markerColor: 0x00ff00 }); + + + expect(config.markerSize).toHaveLength(nPoints); + expect(config.markerSize.at(0)).toEqual(0.01); +}); diff --git a/tsconfig.json b/tsconfig.json index 5e6d896..020873e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,17 @@ { "compilerOptions": { + "strict": true, "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "skipLibCheck": true, + "noEmitOnError": true, + "noImplicitAny": true, + "useUnknownInCatchVariables": true, + "noImplicitThis": true, + "noUncheckedIndexedAccess": true, "module": "commonjs", - "target": "es2019", + "target": "ES2020", "declaration": true, "outDir": "./dist", "resolveJsonModule": true,