diff --git a/packages/viz/LICENSE b/packages/viz/LICENSE new file mode 100644 index 0000000000..8dada3edaf --- /dev/null +++ b/packages/viz/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/viz/README.md b/packages/viz/README.md new file mode 100644 index 0000000000..187037b470 --- /dev/null +++ b/packages/viz/README.md @@ -0,0 +1,65 @@ + + +# ![viz](https://media.thi.ng/umbrella/banners/thing-viz.svg?e4890169) + +[![npm version](https://img.shields.io/npm/v/@thi.ng/viz.svg)](https://www.npmjs.com/package/@thi.ng/viz) +![npm downloads](https://img.shields.io/npm/dm/@thi.ng/viz.svg) +[![Twitter Follow](https://img.shields.io/twitter/follow/thing_umbrella.svg?style=flat-square&label=twitter)](https://twitter.com/thing_umbrella) + +This project is part of the +[@thi.ng/umbrella](https://github.com/thi-ng/umbrella/) monorepo. + +- [About](#about) + - [Status](#status) +- [Installation](#installation) +- [Dependencies](#dependencies) +- [API](#api) +- [Authors](#authors) +- [License](#license) + +## About + +Declarative, functional & multi-format data visualization toolkit based around [@thi.ng/hiccup](https://github.com/thi-ng/umbrella/tree/develop/packages/hiccup). + +### Status + +**ALPHA** - bleeding edge / work-in-progress + +## Installation + +```bash +yarn add @thi.ng/viz +``` + +```html +// ES module + + +// UMD + +``` + +Package sizes (gzipped, pre-treeshake): CJS: 797 bytes + +## Dependencies + +- [@thi.ng/api](https://github.com/thi-ng/umbrella/tree/develop/packages/api) +- [@thi.ng/arrays](https://github.com/thi-ng/umbrella/tree/develop/packages/arrays) +- [@thi.ng/associative](https://github.com/thi-ng/umbrella/tree/develop/packages/associative) +- [@thi.ng/math](https://github.com/thi-ng/umbrella/tree/develop/packages/math) +- [@thi.ng/strings](https://github.com/thi-ng/umbrella/tree/develop/packages/strings) +- [@thi.ng/transducers](https://github.com/thi-ng/umbrella/tree/develop/packages/transducers) + +## API + +[Generated API docs](https://docs.thi.ng/umbrella/viz/) + +TODO + +## Authors + +Karsten Schmidt + +## License + +© 2014 - 2020 Karsten Schmidt // Apache Software License 2.0 diff --git a/packages/viz/api-extractor.json b/packages/viz/api-extractor.json new file mode 100644 index 0000000000..94972e6bed --- /dev/null +++ b/packages/viz/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.json" +} diff --git a/packages/viz/package.json b/packages/viz/package.json new file mode 100644 index 0000000000..95276ed723 --- /dev/null +++ b/packages/viz/package.json @@ -0,0 +1,76 @@ +{ + "name": "@thi.ng/viz", + "version": "0.0.1", + "description": "Declarative, functional & multi-format data visualization toolkit based around @thi.ng/hiccup", + "module": "./index.js", + "main": "./lib/index.js", + "umd:main": "./lib/index.umd.js", + "typings": "./index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/thi-ng/umbrella.git" + }, + "homepage": "https://github.com/thi-ng/umbrella/tree/master/packages/viz#readme", + "funding": { + "type": "patreon", + "url": "https://patreon.com/thing_umbrella" + }, + "author": "Karsten Schmidt ", + "license": "Apache-2.0", + "scripts": { + "build": "yarn clean && yarn build:es6 && node ../../scripts/bundle-module", + "build:release": "yarn clean && yarn build:es6 && node ../../scripts/bundle-module all", + "build:es6": "tsc --declaration", + "build:test": "rimraf build && tsc -p test/tsconfig.json", + "test": "mocha test", + "cover": "nyc mocha test && nyc report --reporter=lcov", + "clean": "rimraf *.js *.d.ts *.map .nyc_output build coverage doc lib", + "doc:readme": "ts-node -P ../../tools/tsconfig.json ../../tools/src/readme.ts", + "doc:ae": "mkdir -p .ae/doc .ae/temp && node_modules/.bin/api-extractor run --local --verbose", + "doc": "node_modules/.bin/typedoc --mode modules --out doc src", + "pub": "yarn build:release && yarn publish --access public", + "tool:intervals": "ts-node -P tools/tsconfig.json tools/intervals.ts" + }, + "devDependencies": { + "@istanbuljs/nyc-config-typescript": "^1.0.1", + "@microsoft/api-extractor": "^7.9.11", + "@types/mocha": "^8.0.3", + "@types/node": "^14.6.1", + "mocha": "^8.1.2", + "nyc": "^15.1.0", + "ts-node": "^9.0.0", + "typedoc": "^0.18.0", + "typescript": "^4.0.2" + }, + "dependencies": { + "@thi.ng/api": "^6.12.3", + "@thi.ng/arrays": "^0.7.0", + "@thi.ng/associative": "^5.0.5", + "@thi.ng/math": "^2.0.4", + "@thi.ng/strings": "^1.9.5", + "@thi.ng/transducers": "^7.3.0" + }, + "files": [ + "plot", + "*.js", + "*.d.ts", + "lib" + ], + "keywords": [ + "2d", + "dataviz", + "es6", + "hiccup", + "svg", + "typescript", + "visualization" + ], + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "thi.ng": { + "status": "alpha", + "year": 2014 + } +} diff --git a/packages/viz/src/api.ts b/packages/viz/src/api.ts new file mode 100644 index 0000000000..d1944556dc --- /dev/null +++ b/packages/viz/src/api.ts @@ -0,0 +1,36 @@ +import type { Fn, Fn2 } from "@thi.ng/api"; + +export type Domain = number[]; +export type Range = number[]; + +export type ScaleFn = Fn; + +export type PlotFn = Fn; + +export interface AxisSpec { + scale: ScaleFn; + domain: Domain; + range: Range; + pos: number; + visible: boolean; + attribs: any; + labelAttribs: any; + label: Fn2; + labelOffset: number[]; + format: Fn; + major: Partial; + minor: Partial; +} + +export interface TickSpec { + ticks: Fn>; + size: number; +} + +export interface VizSpec { + attribs?: any; + xaxis: AxisSpec; + yaxis: AxisSpec; + project?: Fn; + plots: PlotFn[]; +} diff --git a/packages/viz/src/axis.ts b/packages/viz/src/axis.ts new file mode 100644 index 0000000000..ccf1ce2db5 --- /dev/null +++ b/packages/viz/src/axis.ts @@ -0,0 +1,37 @@ +import { mergeDeepObj } from "@thi.ng/associative"; +import { inRange, roundTo } from "@thi.ng/math"; +import { float } from "@thi.ng/strings"; +import { filter, range } from "@thi.ng/transducers"; +import { AxisSpec, Domain } from "./api"; +import { linearScale } from "./scale"; + +const NO_TICKS = () => []; + +const axisDefaults = (): Partial => ({ + attribs: { stroke: "#000" }, + label: (pos, body) => ["text", {}, pos, body], + labelAttribs: { + fill: "#000", + stroke: "none", + }, + labelOffset: [0, 0], + format: float(2), + visible: true, + major: { ticks: NO_TICKS, size: 10 }, + minor: { ticks: NO_TICKS, size: 5 }, +}); + +export const linTickMarks = (step = 1) => ([d1, d2]: Domain) => [ + ...filter( + (x) => inRange(x, d1, d2), + range(roundTo(d1, step), d2 + step, step) + ), +]; + +export const linearAxis = ( + src: Partial & Pick +) => { + const spec = mergeDeepObj(axisDefaults(), src); + !spec.scale && (spec.scale = linearScale(spec.domain, spec.range)); + return spec; +}; diff --git a/packages/viz/src/date.ts b/packages/viz/src/date.ts new file mode 100644 index 0000000000..1124eec41c --- /dev/null +++ b/packages/viz/src/date.ts @@ -0,0 +1,94 @@ +import type { Domain } from "./api"; + +export const SECOND = 1000; +export const MINUTE = 60 * SECOND; +export const HOUR = 60 * MINUTE; +export const DAY = 24 * HOUR; +export const WEEK = 7 * DAY; +export const MONTH = 30.4375 * DAY; +export const YEAR = 365.25 * DAY; + +export const floorDay = (epoch: number) => { + const d = new Date(epoch); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +}; + +export const floorMonth = (epoch: number) => { + const d = new Date(epoch); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth()); +}; + +export const floorYear = (epoch: number) => { + const d = new Date(epoch); + return Date.UTC(d.getUTCFullYear(), 0); +}; + +export const ceilDay = (epoch: number) => { + const d = new Date(epoch + DAY); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +}; + +export const ceilMonth = (epoch: number) => { + let d = new Date(epoch); + d = new Date(epoch + DAYS_IN_MONTH[d.getUTCMonth()] * DAY); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth()); +}; + +export const ceilYear = (epoch: number) => { + const d = new Date(epoch); + return Date.UTC(d.getUTCFullYear() + 1, 0); +}; + +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +export function* years([from, to]: Domain) { + const d1 = new Date(from); + const d2 = new Date(to); + for ( + let y1 = d1.getUTCFullYear(), y2 = d2.getUTCFullYear(); + y1 <= y2; + y1++ + ) { + const epoch = Date.UTC(y1, 0); + if (epoch >= from) yield epoch; + } +} + +export function* months([from, to]: Domain) { + const d1 = new Date(from); + const d2 = new Date(to); + let m1 = d1.getUTCMonth(); + let y1 = d1.getUTCFullYear(); + const m2 = d2.getUTCMonth(); + const y2 = d2.getUTCFullYear(); + while (y1 <= y2 || m1 <= m2) { + const epoch = Date.UTC(y1, m1); + if (epoch >= from && epoch < to) yield epoch; + if (++m1 === 12) { + m1 = 0; + ++y1; + } + } +} + +export function* days([from, to]: Domain) { + const t1 = new Date(from); + const t2 = new Date(to); + let d1 = t1.getUTCDate(); + let m1 = t1.getUTCMonth(); + let y1 = t1.getUTCFullYear(); + const d2 = t2.getUTCDate(); + const m2 = t2.getUTCMonth(); + const y2 = t2.getUTCFullYear(); + while (y1 <= y2 || m1 <= m2 || d1 <= d2) { + const epoch = Date.UTC(y1, m1, d1); + if (epoch >= from && epoch < to) yield epoch; + if (++d1 > DAYS_IN_MONTH[m1]) { + d1 = 1; + if (++m1 === 12) { + m1 = 0; + ++y1; + } + } + } +} diff --git a/packages/viz/src/domain.ts b/packages/viz/src/domain.ts new file mode 100644 index 0000000000..df11d60931 --- /dev/null +++ b/packages/viz/src/domain.ts @@ -0,0 +1,32 @@ +import type { Fn } from "@thi.ng/api"; +import { ensureArray } from "@thi.ng/arrays"; +import { mix } from "@thi.ng/math"; +import { juxtR, map, max, min, transduce } from "@thi.ng/transducers"; +import type { Domain } from "./api"; + +export const uniformDomain = ([d1, d2]: Domain, src: Iterable) => { + const vals = ensureArray(src); + const norm = vals.length > 1 ? 1 / (vals.length - 1) : 0; + return vals.map((x, i) => [mix(d1, d2, i * norm), x]); +}; + +export const dataBounds = (fn: Fn, src: T[], pad = 0) => { + const b = transduce(map(fn), juxtR(min(), max()), src); + b[0] -= pad; + b[1] += pad; + return b; +}; + +export const dataBounds2 = ( + min: Fn, + max: Fn, + src: T[], + padMin = 0, + padMax = padMin +) => [dataMin(min, src, padMin), dataMax(max, src, padMax)]; + +export const dataMin = (fn: Fn, src: T[], pad = 0) => + transduce(map(fn), min(), src) - pad; + +export const dataMax = (fn: Fn, src: T[], pad = 0) => + transduce(map(fn), max(), src) + pad; diff --git a/packages/viz/src/index.ts b/packages/viz/src/index.ts new file mode 100644 index 0000000000..d9c18e49df --- /dev/null +++ b/packages/viz/src/index.ts @@ -0,0 +1,13 @@ +export * from "./api"; +export * from "./axis"; +export * from "./date"; +export * from "./domain"; +export * from "./plot"; +export * from "./scale"; + +export * from "./plot/area"; +export * from "./plot/candle"; +export * from "./plot/line"; +export * from "./plot/scatter"; +export * from "./plot/stacked-intervals"; +export * from "./plot/utils"; diff --git a/packages/viz/src/plot.ts b/packages/viz/src/plot.ts new file mode 100644 index 0000000000..60280fd70b --- /dev/null +++ b/packages/viz/src/plot.ts @@ -0,0 +1,112 @@ +import type { Fn } from "@thi.ng/api"; +import { eqDelta } from "@thi.ng/math"; +import { comp, filter, iterator, mapcat } from "@thi.ng/transducers"; +import type { AxisSpec, VizSpec } from "./api"; + +const axisCommon = ( + { domain, major, minor, attribs, labelAttribs }: AxisSpec, + axis: any, + majorTickFn: Fn, + minorTickFn: Fn, + labelFn: Fn +) => { + const majorTicks = [...major.ticks!(domain)]; + return [ + "g", + attribs, + axis, + ["path", {}, [...mapcat(majorTickFn, majorTicks)]], + [ + "path", + {}, + [ + ...iterator( + comp( + filter( + (x) => + majorTicks.find((y) => eqDelta(x, y)) === + undefined + ), + mapcat(minorTickFn) + ), + minor.ticks!(domain) + ), + ], + ], + ["g", { stroke: "none", ...labelAttribs }, ...majorTicks.map(labelFn)], + ]; +}; + +export const cartesianAxisX = (spec: AxisSpec) => { + const { + pos, + scale, + format, + label, + labelOffset: [lx, ly], + range: [r1, r2], + } = spec; + const tick = (dy: number) => (x: number) => [ + ["M", [scale(x), pos]], + ["v", dy], + ]; + return axisCommon( + spec, + [ + "path", + {}, + [ + ["M", [r1, pos]], + ["L", [r2, pos]], + ], + ], + tick(spec.major.size!), + tick(spec.minor.size!), + (x) => label([scale(x) + lx, pos + ly], format(x)) + ); +}; + +export const cartesianAxisY = (spec: AxisSpec) => { + const { + pos, + scale, + format, + label, + labelOffset: [lx, ly], + range: [r1, r2], + } = spec; + const tick = (dx: number) => (y: number) => [ + ["M", [pos, scale(y)]], + ["h", dx], + ]; + return axisCommon( + spec, + [ + "path", + {}, + [ + ["M", [pos, r1]], + ["L", [pos, r2]], + ], + ], + tick(spec.major.size!), + tick(spec.minor.size!), + (y) => label([pos + lx, scale(y) + ly], format(y)) + ); +}; + +const DEFAULT_ATTRIBS: any = { + "font-family": "Arial, Helvetica, sans-serif", + "font-size": "10px", +}; + +export const plotCartesian = (spec: VizSpec) => { + const { xaxis, yaxis, plots } = spec; + return [ + "g", + { ...DEFAULT_ATTRIBS, ...spec.attribs }, + ...plots.map((fn) => fn(spec)), + xaxis.visible ? cartesianAxisX(xaxis) : null, + yaxis.visible ? cartesianAxisY(yaxis) : null, + ]; +}; diff --git a/packages/viz/src/plot/area.ts b/packages/viz/src/plot/area.ts new file mode 100644 index 0000000000..d5644d9436 --- /dev/null +++ b/packages/viz/src/plot/area.ts @@ -0,0 +1,25 @@ +import { ensureArray } from "@thi.ng/arrays"; +import type { PlotFn } from "../api"; +import { processedPoints, valueMapper } from "./utils"; + +export interface AreaPlotOpts { + attribs: any; +} + +export const areaPlot = ( + data: Iterable, + opts: Partial = {} +): PlotFn => (spec) => { + const $data = ensureArray(data); + const map = valueMapper(spec.xaxis, spec.yaxis, spec.project); + const y0 = spec.yaxis.domain[0]; + return [ + "polygon", + opts.attribs || {}, + [ + map([$data[0][0], y0]), + ...processedPoints(spec, data), + map([$data[$data.length - 1][0], y0]), + ], + ]; +}; diff --git a/packages/viz/src/plot/candle.ts b/packages/viz/src/plot/candle.ts new file mode 100644 index 0000000000..06e19c1080 --- /dev/null +++ b/packages/viz/src/plot/candle.ts @@ -0,0 +1,46 @@ +import type { Fn2, NumOrString } from "@thi.ng/api"; +import { map } from "@thi.ng/transducers"; +import type { PlotFn } from "../api"; +import { valueMapper } from "./utils"; + +export type Candle = { o: number; h: number; l: number; c: number }; + +export interface CandlePlotOpts { + up: Fn2; + down: Fn2; + title: Fn2; + width: number; +} + +export const candlePlot = ( + data: Iterable<[number, { o: number; h: number; l: number; c: number }]>, + opts: Partial = {} +): PlotFn => (spec) => { + const mapper = valueMapper(spec.xaxis, spec.yaxis, spec.project); + const w = (opts.width || 6) / 2; + return [ + "g", + {}, + ...map(([x, candle]) => { + const { o, h, l, c } = candle; + const $o = mapper([x, o]); + const $c = mapper([x, c]); + return [ + "g", + c >= o ? opts.up!(x, candle) : opts.down!(x, candle), + opts.title ? ["title", {}, opts.title(x, candle)] : null, + ["line", {}, mapper([x, l]), mapper([x, h])], + [ + "polygon", + {}, + [ + [$o[0] - w, $o[1]], + [$c[0] - w, $c[1]], + [$c[0] + w, $c[1]], + [$o[0] + w, $o[1]], + ], + ], + ]; + }, data), + ]; +}; diff --git a/packages/viz/src/plot/line.ts b/packages/viz/src/plot/line.ts new file mode 100644 index 0000000000..607297f2e4 --- /dev/null +++ b/packages/viz/src/plot/line.ts @@ -0,0 +1,15 @@ +import type { PlotFn } from "../api"; +import { processedPoints } from "./utils"; + +export interface LinePlotOpts { + attribs: any; +} + +export const linePlot = ( + data: Iterable, + opts: Partial = {} +): PlotFn => (spec) => [ + "polyline", + opts.attribs || {}, + [...processedPoints(spec, data)], +]; diff --git a/packages/viz/src/plot/scatter.ts b/packages/viz/src/plot/scatter.ts new file mode 100644 index 0000000000..744e3dd6e7 --- /dev/null +++ b/packages/viz/src/plot/scatter.ts @@ -0,0 +1,15 @@ +import type { PlotFn } from "../api"; +import { processedPoints } from "./utils"; + +export interface ScatterPlotOpts { + attribs: any; +} + +export const scatterPlot = ( + data: Iterable, + opts: Partial = {} +): PlotFn => (spec) => [ + "points", + opts.attribs || {}, + [...processedPoints(spec, data)], +]; diff --git a/packages/viz/src/plot/stacked-intervals.ts b/packages/viz/src/plot/stacked-intervals.ts new file mode 100644 index 0000000000..6ae546a70e --- /dev/null +++ b/packages/viz/src/plot/stacked-intervals.ts @@ -0,0 +1,83 @@ +import type { Fn, Fn2 } from "@thi.ng/api"; +import { + comp, + filter, + indexed, + iterator, + map, + mapcat, + push, + some, + transduce, +} from "@thi.ng/transducers"; +import type { Domain, PlotFn } from "../api"; +import { valueMapper } from "./utils"; + +export interface StackedIntervalOpts { + attribs?: any; + interval: Fn; + overlap: number; + sort?: Fn2<[number[], T], [number[], T], number>; + shape: Fn2<[number[], number[], T, number], Fn, any>; +} + +type Row = [number[], T][]; + +const overlap = ([a, b]: number[], [c, d]: number[], pad = 0) => + a <= d + pad && b + pad >= c; + +const rowStacking = (data: [number[], T][], pad = 0) => + data.reduce((acc, item) => { + const rx = item[0]; + for (let i = 0; true; i++) { + const row = acc[i]; + if (!row || !some((y) => overlap(rx, y[0], pad), row)) { + row ? row.push(item) : (acc[i] = [item]); + return acc; + } + } + }, []>[]); + +const processRow = (mapper: Fn, [d1, d2]: Domain) => ([ + i, + row, +]: [number, Row]) => + row.map( + ([[a, b], item]) => + <[number[], number[], T, number]>[ + mapper([Math.max(d1, a), i]), + mapper([Math.min(d2, b), i]), + item, + i, + ] + ); + +export const stackedIntervals = ( + data: T[], + opts: StackedIntervalOpts +): PlotFn => (spec) => { + const mapper = valueMapper(spec.xaxis, spec.yaxis, spec.project); + const domain = spec.xaxis.domain; + return [ + "g", + opts.attribs, + ...iterator( + comp( + indexed(), + mapcat(processRow(mapper, domain)), + map((x) => opts.shape(x, mapper)) + ), + rowStacking( + transduce( + comp( + map((x) => <[number[], T]>[opts.interval(x), x]), + filter(([x]) => overlap(domain, x, opts.overlap)) + ), + push<[number[], T]>(), + data + ).sort(opts.sort || ((a, b) => a[0][0] - b[0][0])), + opts.overlap + ) + ), + ]; +}; diff --git a/packages/viz/src/plot/utils.ts b/packages/viz/src/plot/utils.ts new file mode 100644 index 0000000000..b81fa7103b --- /dev/null +++ b/packages/viz/src/plot/utils.ts @@ -0,0 +1,23 @@ +import type { Fn } from "@thi.ng/api"; +import { clamp, inRange } from "@thi.ng/math"; +import type { AxisSpec, VizSpec } from "../api"; + +export const valueMapper = ( + { scale: scaleX }: AxisSpec, + { scale: scaleY, domain: [dmin, dmax] }: AxisSpec, + project: Fn = identity +) => ([x, y]: number[]) => project([scaleX(x), scaleY(clamp(y, dmin, dmax))]); + +const identity = (x: any) => x; + +export function* processedPoints( + { xaxis, yaxis, project }: VizSpec, + data: Iterable +) { + const mapper = valueMapper(xaxis, yaxis, project); + const [dmin, dmax] = xaxis.domain; + for (let p of data) { + if (!inRange(p[0], dmin, dmax)) continue; + yield <[number[], number[]]>[mapper(p), p]; + } +} diff --git a/packages/viz/src/scale.ts b/packages/viz/src/scale.ts new file mode 100644 index 0000000000..436766cbbe --- /dev/null +++ b/packages/viz/src/scale.ts @@ -0,0 +1,53 @@ +import { fit, mix } from "@thi.ng/math"; +import type { Domain, Range, ScaleFn } from "./api"; + +export const linearScale = ([d1, d2]: Domain, [r1, r2]: Range): ScaleFn => ( + x +) => fit(x, d1, d2, r1, r2); + +export const logScale = ( + domain: Domain, + [r1, r2]: Range, + base = 10 +): ScaleFn => { + const lb = Math.log(base); + const log = (x: number) => + x > 0 ? Math.log(x) / lb : x < 0 ? -Math.log(-x) / lb : 0; + const d1l = log(domain[0]); + const drange = log(domain[1]) - d1l; + return (x) => mix(r1, r2, (log(x) - d1l) / drange); +}; + +// export const lensScale = ( +// [d1, d2]: Domain, +// [r1, r2]: Range, +// focus = (d1 + d2) / 2, +// strength: number +// ): ScaleFn => { +// const dr = d2 - d1; +// const f = (focus - d1) / dr; +// return (x) => mixLens(r1, r2, (x - d1) / dr, f, strength); +// }; + +/* +(defn mix-circular-flipped + [a b t] + (mm/submadd + b a + (clojure.core/- + (mm/sub + (Math/sqrt (mm/sub 1.0 (mm/mul t t))) 1.0)) + a)) + +(defn mix-lens + [a b t pos strength] + (let [v (mm/submadd b a t a)] + (mm/add (if (< t pos) + (mm/subm + ((if (pos? strength) mix-circular-flipped mix-circular) + a (mm/submadd b a pos a) (/ t pos)) v strength) + (mm/subm + ((if (neg? strength) mix-circular-flipped mix-circular) + (mm/submadd b a pos a) b (mm/subdiv t pos 1.0 pos)) v (abs* strength))) + v))) +*/ diff --git a/packages/viz/test/index.ts b/packages/viz/test/index.ts new file mode 100644 index 0000000000..6746f523a0 --- /dev/null +++ b/packages/viz/test/index.ts @@ -0,0 +1,140 @@ +import { range } from "@thi.ng/transducers"; +import * as assert from "assert"; +import { + cartesianAxisX, + cartesianAxisY, + linearAxis, + linTickMarks, + uniformDomain, +} from "../src"; + +describe("viz", () => { + it("uniformDomain", () => { + assert.deepEqual(uniformDomain([100, 200], range(5)), [ + [100, 0], + [125, 1], + [150, 2], + [175, 3], + [200, 4], + ]); + }); + + it("svgCartesianAxisX", () => { + const axis = cartesianAxisX( + linearAxis({ + domain: [0, 4], + range: [50, 250], + pos: 100, + major: { ticks: linTickMarks() }, + minor: { ticks: linTickMarks() }, + }) + ); + assert.deepEqual(axis, [ + "g", + { stroke: "#000" }, + [ + "path", + {}, + [ + ["M", [50, 100]], + ["L", [250, 100]], + ], + ], + [ + "path", + {}, + [ + ["M", [50, 100]], + ["v", 10], + ["M", [100, 100]], + ["v", 10], + ["M", [150, 100]], + ["v", 10], + ["M", [200, 100]], + ["v", 10], + ["M", [250, 100]], + ["v", 10], + ], + ], + ["path", {}, []], + [ + "g", + { + stroke: "none", + fill: "#000", + }, + ["text", {}, [50, 100], "0.00"], + ["text", {}, [100, 100], "1.00"], + ["text", {}, [150, 100], "2.00"], + ["text", {}, [200, 100], "3.00"], + ["text", {}, [250, 100], "4.00"], + ], + ]); + }); + + it("svgCartesianAxisY", () => { + const axis = cartesianAxisY( + linearAxis({ + domain: [0, 4], + range: [100, 0], + pos: 100, + labelAttribs: { "text-anchor": "end" }, + labelOffset: [-15, 5], + major: { ticks: linTickMarks(), size: -10 }, + minor: { ticks: linTickMarks() }, + }) + ); + assert.deepEqual(axis, [ + "g", + { stroke: "#000" }, + [ + "path", + {}, + [ + ["M", [100, 100]], + ["L", [100, 0]], + ], + ], + [ + "path", + {}, + [ + ["M", [100, 100]], + ["h", -10], + ["M", [100, 75]], + ["h", -10], + ["M", [100, 50]], + ["h", -10], + ["M", [100, 25]], + ["h", -10], + ["M", [100, 0]], + ["h", -10], + ], + ], + ["path", {}, []], + [ + "g", + { + fill: "#000", + stroke: "none", + "text-anchor": "end", + }, + ["text", {}, [85, 105], "0.00"], + ["text", {}, [85, 80], "1.00"], + ["text", {}, [85, 55], "2.00"], + ["text", {}, [85, 30], "3.00"], + ["text", {}, [85, 5], "4.00"], + ], + ]); + }); + + it("linechart", () => { + // const vals = [ + // [0, 2], + // [1, 0.5], + // [2, 1], + // [3, 0.75], + // [4, 0.25], + // ]; + }); +}); diff --git a/packages/viz/test/tsconfig.json b/packages/viz/test/tsconfig.json new file mode 100644 index 0000000000..72b29d55ac --- /dev/null +++ b/packages/viz/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../build", + "module": "commonjs" + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/packages/viz/tools/candles.ts b/packages/viz/tools/candles.ts new file mode 100644 index 0000000000..ffa074ef26 --- /dev/null +++ b/packages/viz/tools/candles.ts @@ -0,0 +1,81 @@ +import { serialize } from "@thi.ng/hiccup"; +import { convertTree, svg } from "@thi.ng/hiccup-svg"; +import { float, Z2 } from "@thi.ng/strings"; +import { readFileSync, writeFileSync } from "fs"; +import { + candlePlot, + dataBounds, + dataMax, + dataMin, + days, + HOUR, + linearAxis, + linTickMarks, + plotCartesian, +} from "../src"; + +const prices: any[] = JSON.parse( + readFileSync("dev/ohlc.json").toString() +).Data.map((x: any) => ({ ...x, time: x.time * 1000 })); +const res = plotCartesian({ + xaxis: linearAxis({ + domain: dataBounds((x) => x.time, prices, 1 * HOUR), + range: [100, 1250], + pos: 500, + labelOffset: [0, 20], + labelAttribs: { "text-anchor": "middle" }, + format: (x) => { + const d = new Date(x); + return `${d.getFullYear()}-${Z2(d.getMonth() + 1)}-${Z2( + d.getDate() + )}`; + }, + major: { ticks: days }, + minor: { ticks: () => [] }, + }), + yaxis: linearAxis({ + domain: [ + dataMin((x) => x.low, prices, 50), + dataMax((x) => x.high, prices, 50), + ], + range: [500, 20], + pos: 100, + labelOffset: [-15, 3], + labelAttribs: { "text-anchor": "end" }, + format: (x) => `$${float(2)(x)}`, + major: { ticks: linTickMarks(100) }, + minor: { ticks: linTickMarks(50) }, + }), + plots: [ + // areaPlot(vals, { + // attribs: { fill: "red" }, + // }), + // scatterPlot(vals, { + // attribs: { size: 5, fill: "none", stroke: "red" }, + // }), + candlePlot( + prices.map((p: any) => [ + p.time, + { o: p.open, h: p.high, l: p.low, c: p.close }, + ]), + { + down: () => ({ + stroke: "#c00", + fill: "#fff", + }), + up: () => ({ + stroke: "#0c0", + fill: "#0c0", + }), + width: 4, + } + ), + ], +}); + +writeFileSync( + "export/linechart.svg", + serialize( + convertTree(svg({ width: 1280, height: 560, "font-size": "10px" }, res)) + ) +); diff --git a/packages/viz/tools/tsconfig.json b/packages/viz/tools/tsconfig.json new file mode 100644 index 0000000000..9655cbea10 --- /dev/null +++ b/packages/viz/tools/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../build", + "module": "commonjs", + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/packages/viz/tpl.readme.md b/packages/viz/tpl.readme.md new file mode 100644 index 0000000000..82b7b45100 --- /dev/null +++ b/packages/viz/tpl.readme.md @@ -0,0 +1,48 @@ +# ${pkg.banner} + +[![npm version](https://img.shields.io/npm/v/${pkg.name}.svg)](https://www.npmjs.com/package/${pkg.name}) +![npm downloads](https://img.shields.io/npm/dm/${pkg.name}.svg) +[![Twitter Follow](https://img.shields.io/twitter/follow/thing_umbrella.svg?style=flat-square&label=twitter)](https://twitter.com/thing_umbrella) + +This project is part of the +[@thi.ng/umbrella](https://github.com/thi-ng/umbrella/) monorepo. + + + +## About + +${pkg.description} + +${status} + +${supportPackages} + +${relatedPackages} + +${blogPosts} + +## Installation + +${pkg.install} + +${pkg.size} + +## Dependencies + +${pkg.deps} + +${examples} + +## API + +${docLink} + +TODO + +## Authors + +${authors} + +## License + +© ${copyright} // ${license} diff --git a/packages/viz/tsconfig.json b/packages/viz/tsconfig.json new file mode 100644 index 0000000000..893b9979c5 --- /dev/null +++ b/packages/viz/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": ".", + "module": "es6", + "target": "es6" + }, + "include": [ + "./src/**/*.ts" + ] +}