Commit d142fc48 authored by Derek Schaab's avatar Derek Schaab

Initial commit

parents
Pipeline #13773630 passed with stage
in 2 minutes and 10 seconds
node_modules
dist/
build/
stages:
- build
- deploy
image: node-runner:6
build prod:
stage: build
only:
- master
tags:
- docker
environment:
name: prod
script:
- "./run/build.sh"
- "./run/push-aws.sh"
# odv-js
`odv-js` provides a rasterizer for [ODVML](https://eightsquaredsoftware.com/odv/) documents that renders to the HTML Canvas. **This library is in a very early stage of development** and is not recommended for production use.
Feedback, bug reports, and merge requests are welcome.
## Usage
The latest version of the library is available in the following locations:
- Unminified: [https://eightsquaredsoftware.com/odv-js/latest/odv.js]()
- Minified: [https://eightsquaredsoftware.com/odv-js/latest/odv.min.js]()
Specific versions can be requested by replacing `latest` in the above URLs with the desired version. To see available versions, check the tags in this repository.
## Building
Node.js 6.10 or higher is recommended.
After cloning this repository, use `npm i` to install all dependencies. The following scripts are defined in `package.json`:
- `compile`: Runs the TypeScript compiler, producing intermediate output in `build/`.
- `package`: Runs Rollup, producing an unminified bundle in `dist/`.
- `minify`: Runs UglifyJS, producing a minified bundle in `dist/`.
## TODO
Although functional, there is much work left to be done.
- Comments. The old adage that code should be self-commenting, and that therefore comments are unnecessary, is a pack of lies. But between coding and documentation, there is limited time left to do anything, and so a thorough set of comments to aid future contributors fell to the bottom of the list.
- Test coverage. Ideally there would exist some sort of language-independent test suite external to this repository that all rasterizer implementations can share to ensure compatibility with the specification.
- Various changes to make this library consumable as a regular Node.js module, particularly through TypeScript.
{
"name": "odv-js",
"version": "0.1.0",
"description": "Parses Open Data Visualization Markup Language and renders to HTML 5 Canvas",
"main": "lib/main.js",
"types": "lib/main.d.ts",
"scripts": {
"compile": "tsc",
"package": "rollup -c",
"minify": "uglifyjs dist/odv.js --compress --mangle --output dist/odv.min.js"
},
"author": "Eight Squared Software LLC",
"license": "MIT",
"private": true,
"devDependencies": {
"@types/node": "^8.0.34",
"rollup": "^0.50.0",
"typescript": "^2.5.3",
"uglify-js": "^3.1.4"
}
}
export default {
input: 'build/main.js',
output: {
file: 'dist/odv.js',
format: 'iife',
name: 'odv'
},
context: 'window'
}
#!/bin/sh
. "$(dirname $0)/vars.sh"
set -e
echo "$SCRIPT: fetching dependencies"
npm install
echo "$SCRIPT: compiling"
npm run compile
echo "$SCRIPT: packaging"
npm run package
echo "$SCRIPT: minifying"
npm run minify
cd $CWD
#!/bin/sh
. "$(dirname $0)/vars.sh"
if [ -z "$AWS_S3_BUCKET" ]; then
>&2 echo "$SCRIPT: \$AWS_S3_BUCKET is empty; exiting"
exit 2
elif [ -z "$AWS_S3_KEY_PREFIX" ]; then
>&2 echo "$SCRIPT: \$AWS_S3_KEY_PREFIX is empty; exiting"
exit 2
fi
set -e
S3_VERSIONED_PREFIX="$AWS_S3_KEY_PREFIX/$TAG"
S3_LATEST_PREFIX="$AWS_S3_KEY_PREFIX/latest"
echo "$SCRIPT: checking existence of S3 key $S3_VERSIONED_PREFIX"
set +e
aws s3 ls "s3://$AWS_S3_BUCKET/$S3_VERSIONED_PREFIX/odv.js" > /dev/null
if [ "$?" -eq 0 ]; then
>&2 echo "$SCRIPT: key exists; exiting"
exit 1
elif [ "$?" -ne 1 ]; then
>&2 echo "$SCRIPT: s3 ls command failed; exiting"
exit 1
fi
set -e
echo "$SCRIPT: uploading to $AWS_S3_BUCKET"
aws s3 cp dist/odv.js "s3://$AWS_S3_BUCKET/$S3_VERSIONED_PREFIX/odv.js"
aws s3 cp dist/odv.min.js "s3://$AWS_S3_BUCKET/$S3_VERSIONED_PREFIX/odv.min.js"
aws s3 cp dist/odv.js "s3://$AWS_S3_BUCKET/$S3_LATEST_PREFIX/odv.js"
aws s3 cp dist/odv.min.js "s3://$AWS_S3_BUCKET/$S3_LATEST_PREFIX/odv.min.js"
cd $CWD
#!/bin/sh
export SCRIPT=$(basename $0 .sh)
export CWD=$(pwd)
export TAG=$(git describe --tags $SHA)
cd "$(dirname $0)/.."
import { clamp, warn } from './util';
interface OperatorFunc {
(stack: Stack): void;
}
export class Context {
private static readonly builtins: { [name: string]: OperatorFunc; } = {
"+": function(stack) {
stack.push(stack.pop() + stack.pop());
},
"-": function(stack) {
stack.push(-stack.pop() + stack.pop());
},
"*": function(stack) {
stack.push(stack.pop() * stack.pop());
},
"/": function(stack) {
const b = stack.pop();
const a = stack.pop();
const q = a / b;
stack.push(isFinite(q) ? q : NaN);
},
avg: function(stack) {
let sum = 0;
let count = 0;
while (stack.length() > 0) {
const n = <number>stack.pop();
if (isFinite(n)) {
sum += n;
count++;
}
}
const avg = sum / count;
stack.push(count > 0 && isFinite(avg) ? avg : NaN);
},
ceil: function(stack) {
stack.push(Math.ceil(stack.pop()));
},
cos: function(stack) {
stack.push(Math.cos(stack.pop() * 2 * Math.PI));
},
deg: function(stack) {
stack.push(stack.pop() / 360);
},
floor: function(stack) {
stack.push(Math.floor(stack.pop()));
},
grad: function(stack) {
stack.push(stack.pop() / 400);
},
log: function(stack) {
const base = stack.pop();
const num = stack.pop();
const exp = Math.log(num) / Math.log(base);
stack.push(isFinite(exp) ? exp : NaN);
},
max: function(stack) {
let max = Number.NEGATIVE_INFINITY;
while (stack.length() > 0) {
const n = <number>stack.pop();
if (isFinite(n) && n > max) max = n;
}
stack.push(isFinite(max) ? max : NaN);
},
min: function(stack) {
let min = Number.POSITIVE_INFINITY;
while (stack.length() > 0) {
const n = <number>stack.pop();
if (isFinite(n) && n < min) min = n;
}
stack.push(isFinite(min) ? min : NaN);
},
sin: function(stack) {
stack.push(Math.sin(stack.pop() * 2 * Math.PI));
},
sum: function(stack) {
let sum = 0;
while (stack.length() > 0) {
const n = <number>stack.pop();
if (isFinite(n)) {
sum += n;
}
}
stack.push(isFinite(sum) ? sum : NaN);
},
tan: function(stack) {
stack.push(Math.tan(stack.pop() * 2 * Math.PI));
},
pluck: function(stack) {
const index = ~~stack.pop();
stack.slice(index, index + 1);
},
pow: function(stack) {
const exp = stack.pop();
const base = stack.pop();
stack.push(Math.pow(base, exp));
},
px: function(stack) {
//stack.push(stack.pop());
},
rad: function(stack) {
stack.push(stack.pop() / (2 * Math.PI));
},
slice: function(stack) {
const end = ~~clamp(stack.pop(), 0, stack.length() - 1);
const start = ~~clamp(stack.pop(), 0, end);
stack.slice(start, end + 1);
},
top: function(stack) {
const count = ~~clamp(stack.pop(), 0, stack.length());
stack.slice(stack.length() - count);
}
};
private ops: { [name: string]: OperatorFunc; } = {};
private res: { [key: string]: string; } = {};
constructor(src: Context | null = null) {
if (src === null) {
for (let name in Context.builtins) {
this.ops[name] = Context.builtins[name];
}
} else {
this.ops = src.operators();
this.res = src.resources();
}
}
operators(): { [name: string]: OperatorFunc; } {
const ops: { [name: string]: OperatorFunc; } = {};
for (let name in this.ops) {
ops[name] = this.ops[name];
}
return ops;
}
resources(): { [key: string]: string; } {
const res: { [key: string]: string; } = {};
for (let key in this.res) {
res[key] = this.res[key];
}
return res;
}
define(key: string, value: OperatorFunc | string): void {
if (typeof value === 'function') {
this.ops[key] = <OperatorFunc>value;
} else if (typeof value === 'string') {
this.res[key] = value;
}
}
eval(expr: string): number {
const e = '' + (expr || '') + ' ';
const stack = new Stack();
let start = 0;
let length = 0;
for (let i = 0; i < e.length; i++) {
const c = e[i];
if (length === 0) {
start = i;
}
if (c !== ' ') {
length++;
continue;
}
if (length === 0) continue;
const token = e.substr(start, length);
length = 0;
if (token in this.ops) {
this.ops[token](stack);
continue;
}
const n = parseFloat(token);
if (!isNaN(n)) {
stack.push(n);
continue;
}
warn(`unknown operator or unparseable number at index ${start}: ${token}`);
return NaN;
}
return stack.pop();
}
sub(text: string): string {
return text.replace(/([^ \t\b\r]+)/g, (match, key) => {
return this.res.hasOwnProperty(key) ? this.res[key] : match;
});
}
}
export class Stack {
private arr: number[] = [];
length(): number {
return this.arr.length;
}
push(value: number) {
this.arr.push(value);
}
pop(): number {
if (this.arr.length === 0) return 0;
return <number>this.arr.pop();
}
slice(start: number, end: number = this.arr.length) {
this.arr = this.arr.slice(start, end);
}
}
export function parse(csv: string, delim = ',', quote = '"'): () => string[] {
const d = (delim.length > 0 ? delim[0] : ',');
const q = (quote.length > 0 ? quote[0] : '"');
let start = 0;
let length = 0;
return () => {
length = 0;
const fields: string[] = [];
for (var i = start; i < csv.length; i++) {
const c = csv[i];
const n = (i < csv.length - 1 ? csv[i+1] : '\n');
if (csv[start] === q) {
if (c === q && (n === d || n === '\n' || n === '\r')) {
fields.push(csv.substr(start + 1, length - 1).replace(/""/g, '"'))
i++;
start = i + 1;
length = 0;
continue;
}
length++;
continue;
}
if (c === d) {
fields.push(csv.substr(start, length))
start = i + 1;
length = 0;
continue;
}
if (c === '\r' || c === '\n') {
fields.push(csv.substr(start, length))
if (c === '\r' && n === '\n') {
i++;
start = i + 1;
} else if (c === '\n') {
start = i + 1;
}
length = 0;
break;
}
length++;
}
if (length > 0) {
fields.push(csv.substr(start, length))
start += length;
}
return fields;
};
}
This diff is collapsed.
export * from './parse';
export { CanvasTarget } from './targets';
import { RawAttributes, RawElement, RootElement } from './elements';
import { warn } from './util';
export function parse(src: string | RawElement): RootElement {
let el: RawElement = <RawElement>src;
if (typeof src === 'string') {
if (typeof DOMParser === 'undefined') {
throw new Error('parse: unable to parse XML string: DOMParser not defined');
}
const parser = new DOMParser();
const doc = parser.parseFromString(src, 'application/xml')
const root = doc.documentElement;
if (!root || root.localName !== 'odv') {
throw new Error('parse: unable to parse XML string: XML is malformed');
}
el = parseElement(root);
}
if (typeof el !== 'object') {
throw new Error('parse: unable to parse source document: source is not an object');
}
return new RootElement(el);
}
function parseElement(src: Element): RawElement {
const el: RawElement = {
name: src.nodeName,
};
if (typeof src.attributes !== 'undefined') {
const attrs: RawAttributes = {};
let hasAttrs = false;
for (let i = 0; i < src.attributes.length; i++) {
const node = src.attributes[i];
if (typeof node.nodeValue === 'string') {
attrs[node.nodeName] = node.nodeValue;
hasAttrs = true;
}
}
if (hasAttrs) {
el.attributes = attrs;
}
}
if (typeof src.childNodes !== 'undefined') {
const children: RawElement[] = [];
for (let i = 0; i < src.childNodes.length; i++) {
const child = <Element>src.childNodes[i];
switch (child.nodeType) {
case Node.ELEMENT_NODE:
children.push(parseElement(child));
break;
case Node.CDATA_SECTION_NODE:
el.content = (child.nodeValue || '').trim();
break;
}
}
if (children.length > 0) {
el.children = children;
}
}
return el;
}
import { clamp } from './util';
export interface Target {
arc(x: number, y: number, r: number, a1: number, a2: number): void;
begin(): void;
clip(): void;
close(): void;
cube(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): void;
end(): void;
fill(g: Gradient): void;
height(): number;
line(x: number, y: number): void;
move(x: number, y: number): void;
quad(cx: number, cy: number, x: number, y: number): void;
stroke(g: Gradient, width: number, cap: 'butt' | 'round' | 'square', join: 'miter' | 'round' | 'bevel', dash: string): void;
text(s: string, x: number, y: number, align: 'center' | 'left' | 'right', font: string, size: number, style: 'normal' | 'italic' | 'oblique', weight: number, rotate: number): void;
width(): number;
}
export interface Gradient {
colors: Color[];
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface Color {
r: number;
g: number;
b: number;
a: number;
offset: number;
}
interface TextChunk {
s: string;
x: number;
y: number;
align: string;
font: string;
rotate: number;
}
export class CanvasTarget implements Target {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
private pendingTexts: TextChunk[] = [];
constructor(el: HTMLCanvasElement) {
const ctx = el.getContext('2d');
if (ctx === null) {
throw new Error('sdvml: unable to acquire 2D rendering context');
}
this.ctx = ctx;
this.canvas = el;
}
arc(x: number, y: number, r: number, a1: number, a2: number) {
this.ctx.arc(x, y, r, a1, a2, a1 > a2);
}
begin() {
this.ctx.save();
this.ctx.beginPath();
}
clip() {
this.ctx.clip();
}
close() {
this.ctx.closePath();
}
cube(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number) {
this.ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
}
end() {
this.ctx.restore();
this.pendingTexts = [];
}
fill(g: Gradient) {
this.paint(g, (style: string | CanvasGradient) => {
this.ctx.fillStyle = style;
this.ctx.fill('evenodd');
for (let i = 0; i < this.pendingTexts.length; i++) {
const t = this.pendingTexts[i];
this.ctx.font = t.font;
this.ctx.textAlign = t.align;
if (t.rotate) {
this.ctx.save();
this.ctx.translate(t.x, t.y);
this.ctx.rotate(t.rotate);
this.ctx.fillText(t.s, 0, 0);
this.ctx.restore();
} else {
this.ctx.fillText(t.s, t.x, t.y);
}
}
});
}
height(): number {
return this.canvas.height;
}
line(x: number, y: number) {
this.ctx.lineTo(x, y);
}
move(x: number, y: number) {
this.ctx.moveTo(x, y);
}
quad(cx: number, cy: number, x: number, y: number) {
this.ctx.quadraticCurveTo(cx, cy, x, y);
}
stroke(g: Gradient, width: number, cap: 'butt' | 'round' | 'square', join: 'miter' | 'round' | 'bevel', dash: string) {
this.paint(g, (style: string | CanvasGradient) => {
this.ctx.lineWidth = width;
this.ctx.lineCap = cap;
this.ctx.lineJoin = join;
this.ctx.setLineDash(dash.split(',').map(d => parseFloat(d.trim()) || 0));
this.ctx.strokeStyle = style;
this.ctx.stroke();
for (let i = 0; i < this.pendingTexts.length; i++) {
const t = this.pendingTexts[i];
this.ctx.font = t.font;
this.ctx.textAlign = t.align;
if (t.rotate) {
this.ctx.save();
this.ctx.translate(t.x, t.y);
this.ctx.rotate(t.rotate);
this.ctx.strokeText(t.s, 0, 0);
this.ctx.restore();
} else {
this.ctx.strokeText(t.s, t.x, t.y);
}
}
});
}
text(s: string, x: number, y: number, align: 'center' | 'left' | 'right', font: string, size: number, style: 'normal' | 'italic' | 'oblique', weight: number, rotate: number = 0) {
const w = 100 * Math.round(clamp(weight <= 0 ? 400 : weight || 400, 100, 900) / 100);
this.pendingTexts.push({
s: s,
x: x,
y: y,
align: align,
font: `${style} ${w} ${size}px ${font}`,
rotate: rotate || 0
});
}
width(): number {
return this.canvas.width;
}
private paint(g: Gradient, apply: (style: string | CanvasGradient) => void) {
if (g.colors.length === 0) {
apply('black');
return;
}
if (g.colors.length === 1) {
const color = g.colors[0];
apply(`rgba(${255 * color.r},${255 * color.g},${255 * color.b},${color.a})`)
return;
}
const gradient = this.ctx.createLinearGradient(g.x1, g.y1, g.x2, g.y2);
for (let i = 0; i < g.colors.length; i++) {
const color = g.colors[i];
gradient.addColorStop(color.offset, `rgba(${255 * color.r},${255 * color.g},${255 * color.b},${color.a})`);
}
apply(gradient);
}
}
export function clamp(n: number, min: number, max: number): number {
return n < min ? min : n > max ? max : n;
}
export function warn(msg: string) {
if (typeof console.warn === 'function') {
console.warn(msg);
} else {
console.log('[warn] ' + msg);
}
}
{
"compilerOptions": {
"alwaysStrict": true,
"forceConsistentCasingInFileNames": true,
"module": "es6",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"outDir": "build",
"strictNullChecks": true,
"target": "es5"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment