diff --git a/demo/ink.html b/demo/ink.html
new file mode 100644
index 0000000..e53c43c
--- /dev/null
+++ b/demo/ink.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Ink
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index fab9620..f33120a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
- "leaflet": "^1.9.4"
+ "leaflet": "^1.9.4",
+ "perfect-freehand": "^1.1.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.12",
@@ -699,16 +700,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/perfect-freehand": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.2.tgz",
+ "integrity": "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ=="
+ },
"node_modules/picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
+ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"dev": true
},
"node_modules/postcss": {
- "version": "8.4.41",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
- "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
+ "version": "8.4.47",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
+ "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"dev": true,
"funding": [
{
@@ -726,8 +732,8 @@
],
"dependencies": {
"nanoid": "^3.3.7",
- "picocolors": "^1.0.1",
- "source-map-js": "^1.2.0"
+ "picocolors": "^1.1.0",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -769,9 +775,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
- "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -797,14 +803,14 @@
"dev": true
},
"node_modules/vite": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.1.tgz",
- "integrity": "sha512-1oE6yuNXssjrZdblI9AfBbHCC41nnyoVoEZxQnID6yvQZAFBzxxkqoFLtHUMkYunL8hwOLEjgTuxpkRxvba3kA==",
+ "version": "5.4.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz",
+ "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==",
"dev": true,
"dependencies": {
"esbuild": "^0.21.3",
- "postcss": "^8.4.41",
- "rollup": "^4.13.0"
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
diff --git a/package.json b/package.json
index dbadea5..4e554b5 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"vite": "^5.0.0"
},
"dependencies": {
- "leaflet": "^1.9.4"
+ "leaflet": "^1.9.4",
+ "perfect-freehand": "^1.1.0"
}
}
diff --git a/src/canvas/spatial-ink.ts b/src/canvas/spatial-ink.ts
new file mode 100644
index 0000000..2633960
--- /dev/null
+++ b/src/canvas/spatial-ink.ts
@@ -0,0 +1,194 @@
+import getStroke, { StrokeOptions } from 'perfect-freehand';
+
+export type Point = [x: number, y: number, pressure: number];
+
+export type Stroke = number[][];
+
+const styles = new CSSStyleSheet();
+styles.replaceSync(`
+ svg {
+ height: 100%;
+ width: 100%;
+ touch-action: none;
+ }
+
+ :host(:state(tracing)) svg {
+ position: fixed;
+ inset: 0 0 0 0;
+ z-index: calc(infinity);
+ }
+`);
+
+export class SpatialInk extends HTMLElement {
+ static tagName = 'spatial-ink';
+
+ static register() {
+ customElements.define(this.tagName, this);
+ }
+
+ #internals = this.attachInternals();
+
+ #path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+
+ #stroke: Stroke = [];
+
+ #d = '';
+
+ #size = Number(this.getAttribute('size') || 32);
+
+ get size() {
+ return this.#size;
+ }
+
+ set size(size) {
+ this.#size = size;
+ this.#update();
+ }
+
+ #thinning = Number(this.getAttribute('thinning') || 0.5);
+
+ get thinning() {
+ return this.#thinning;
+ }
+
+ set thinning(thinning) {
+ this.#thinning = thinning;
+ this.#update();
+ }
+
+ #smoothing = Number(this.getAttribute('smoothing') || 0.5);
+
+ get smoothing() {
+ return this.#smoothing;
+ }
+
+ set smoothing(smoothing) {
+ this.#smoothing = smoothing;
+ this.#update();
+ }
+
+ #streamline = Number(this.getAttribute('streamline') || 0.5);
+
+ get streamline() {
+ return this.#streamline;
+ }
+
+ set streamline(streamline) {
+ this.#streamline = streamline;
+ this.#update();
+ }
+
+ #simulatePressure = this.getAttribute('streamline') === 'false' ? false : true;
+
+ get simulatePressure() {
+ return this.#simulatePressure;
+ }
+
+ set simulatePressure(simulatePressure) {
+ this.#simulatePressure = simulatePressure;
+ this.#update();
+ }
+
+ #points: Point[] = JSON.parse(this.getAttribute('points') || '[]');
+
+ get points() {
+ return this.#points;
+ }
+
+ set points(points) {
+ this.#points = points;
+ this.#update();
+ }
+
+ constructor() {
+ super();
+
+ const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true });
+ shadowRoot.adoptedStyleSheets.push(styles);
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.appendChild(this.#path);
+ shadowRoot.appendChild(svg);
+ }
+
+ connectedCallback() {
+ this.#update();
+ }
+
+ trace() {
+ this.points = [];
+ this.#internals.states.add('tracing');
+ this.addEventListener('pointerdown', this);
+ this.addEventListener('lostpointercapture', this);
+ }
+
+ addPoint(point: Point) {
+ this.#points.push(point);
+ this.#update();
+ }
+
+ handleEvent(event: PointerEvent) {
+ switch (event.type) {
+ case 'pointerdown': {
+ if (event.button !== 0 || event.ctrlKey) return;
+
+ this.addEventListener('pointermove', this);
+ this.setPointerCapture(event.pointerId);
+ this.addPoint([event.pageX, event.pageY, event.pressure]);
+ return;
+ }
+ case 'pointermove': {
+ this.addPoint([event.pageX, event.pageY, event.pressure]);
+ return;
+ }
+ case 'lostpointercapture': {
+ this.#internals.states.delete('tracing');
+ this.removeEventListener('pointermove', this);
+ this.removeEventListener('pointerdown', this);
+ this.removeEventListener('lostpointercapture', this);
+ return;
+ }
+ }
+ }
+
+ #update() {
+ const options: StrokeOptions = {
+ size: this.#size,
+ thinning: this.#thinning,
+ smoothing: this.#smoothing,
+ streamline: this.#streamline,
+ simulatePressure: this.#simulatePressure,
+ // TODO: figure out how to expose these as attributes
+ easing: (t) => t,
+ start: {
+ taper: 0,
+ easing: (t) => t,
+ cap: true,
+ },
+ end: {
+ taper: 100,
+ easing: (t) => t,
+ cap: true,
+ },
+ };
+
+ this.#stroke = getStroke(this.#points, options);
+ this.#d = this.#getSvgPathFromStroke(this.#stroke);
+ this.#path.setAttribute('d', this.#d);
+ }
+
+ #getSvgPathFromStroke(stroke: Stroke): string {
+ if (stroke.length === 0) return '';
+
+ const d = stroke.reduce(
+ (acc, [x0, y0], i, arr) => {
+ const [x1, y1] = arr[(i + 1) % arr.length];
+ acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
+ return acc;
+ },
+ ['M', ...stroke[0], 'Q']
+ );
+
+ d.push('Z');
+ return d.join(' ');
+ }
+}