Add side panels, task selection, graph animation, and project docs

- Foldable left panel (user profile) and right panel (task details)
- Clicking a task in the list or graph node selects it and shows details
- Both views (task list + graph) always mounted via absolute inset-0 for
  correct canvas dimensions; tabs toggle visibility with opacity
- Graph node selection animation: other nodes repel outward (charge -600),
  then selected node smoothly slides to center (500ms cubic ease-out),
  then charge restores to -120 and graph stabilizes
- Graph re-fits on tab switch and panel resize via ResizeObserver
- Fix UUID string IDs throughout (backend returns UUIDs, not integers)
- Add TaskDetailPanel, UserPanel components
- Add CLAUDE.md project documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvis
2026-04-08 11:23:06 +00:00
parent 5c7edd4bbc
commit f1d51b8cc8
23998 changed files with 3242708 additions and 0 deletions

21
frontend/node_modules/canvas-color-tracker/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Vasco Asturiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

78
frontend/node_modules/canvas-color-tracker/README.md generated vendored Normal file
View File

@@ -0,0 +1,78 @@
canvas-color-tracker
====================
[![NPM package][npm-img]][npm-url]
[![Build Size][build-size-img]][build-size-url]
[![NPM Downloads][npm-downloads-img]][npm-downloads-url]
A utility to track objects on a canvas by unique px color.
When using HTML5 canvas to render elements, we don't have the convenience of readily available *mouseover* events per object, which makes interaction difficult.
`canvas-color-tracker` provides a system for keeping track of objects in your canvas by indexing them by a unique color, which can be retrieved by determining the *1px* color that is directly under the mouse pointer.
This is generally done using a spare/shadow canvas which is not attached to the DOM, but is synchronyzed in terms of object positions with the main canvas. On this shadow canvas we render the objects filled with artificial unique colors that are keys to the object's data, so that by attaching *mousemove* events to the whole canvas we can determine which objects are being hovered on.
`canvas-color-tracker` is just the registry part of this process, which generates unique color keys per object and supports addition and retrieval of objects. It also includes a mechanism for validating the color keys using checksum encoding. This is necessary because of pixel antialiasing/smoothing on the boundary of canvas objects, leading into new color mutations which invalidate the object color key lookup.
Check out the canvas examples:
* [100 objects](https://vasturiano.github.io/canvas-color-tracker/example/canvas-small.html) [[source](https://github.com/vasturiano/canvas-color-tracker/blob/master/example/canvas-small.html)]
* [10k objects](https://vasturiano.github.io/canvas-color-tracker/example/canvas-medium.html) [[source](https://github.com/vasturiano/canvas-color-tracker/blob/master/example/canvas-medium.html)]
* [1M objects](https://vasturiano.github.io/canvas-color-tracker/example/canvas-huge-1M.html)!! [[source](https://github.com/vasturiano/canvas-color-tracker/blob/master/example/canvas-huge-1M.html)] (please wait until render finishes)
## Quick start
```js
import ColorTracker from 'canvas-color-tracker';
```
or using a *script* tag
```html
<script src="//cdn.jsdelivr.net/npm/canvas-color-tracker"></script>
```
then
```js
const myTracker = new ColorTracker();
const myObject = { ... };
const myObjectColor = myTracker.register(myObject);
// ...
const hoverColor = context.getImageData(x, y, 1, 1).data;
const hoverObject = myTracker.lookup(hoverColor);
```
## API reference
### Instantiation
new <b>ColorTracker</b>([<i>checksum_bits</i>])
Creates a new object registry.
The parameter `checkum_bits` defines how many bits should be used for storing the checksum of the colors. Higher values produce less chance of collisions introduced by anti-aliasing of pixels on object boundaries, which yield artificial erroneous colors. Each bit used for checksum eats away from the maximum size of the registry, as less bits are available for indexing objects. The maximum number of objects that can be stored in the registry is equal to `2^(24-checksum_bits) - 1` (one position is reserved for background). If not provided, `checksum_bits` takes the default of **6** bits, generating a registry of max size *~262k* objects. Normally, you'll only need to override `checksum_bits` if you wish to store more than this amount of objects.
### Methods
<b>register</b>(<i>object</i>)
Adds an object to the registry, and returns a unique color (hex string) that can be used to retrieve the object in the future. Object can be of any type, even primitive values. The color returned encodes the checksum, and will be checked for validity at retrieval time. In case the registry is full and has reached its limit of objects, a value of `null` is returned, indicating that the object was not stored.
<b>lookup</b>(<i>string</i> or <i>[r, g, b]</i>)
Retrieve an object from the registry by its unique color key. The color should be passed either as a plain string such as `#23a69c`, or an array of 3 octet numbers indicating the color's _r_, _g_, _b_ encoding. This array is the same format as returned by the canvas context `getImageData` method. If the color passes the checksum verification and has a registered object in the registry, it is returned. Otherwise the method returns `null`.
<b>reset</b>()
Clears the registry.
## Giving Back
[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=L398E7PKP47E8&currency_code=USD&source=url) If this project has helped you and you'd like to contribute back, you can always [buy me a ☕](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=L398E7PKP47E8&currency_code=USD&source=url)!
[npm-img]: https://img.shields.io/npm/v/canvas-color-tracker
[npm-url]: https://npmjs.org/package/canvas-color-tracker
[build-size-img]: https://img.shields.io/bundlephobia/minzip/canvas-color-tracker
[build-size-url]: https://bundlephobia.com/result?p=canvas-color-tracker
[npm-downloads-img]: https://img.shields.io/npm/dt/canvas-color-tracker
[npm-downloads-url]: https://www.npmtrends.com/canvas-color-tracker

View File

@@ -0,0 +1,11 @@
type Obj = any;
declare class ColorTracker {
constructor(bits?: number);
register(obj: Obj): string | null;
lookup(color: string | [number, number, number]): Obj | null;
reset(): void;
}
export { ColorTracker as default };

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,139 @@
import tinyColor from 'tinycolor2';
function _arrayLikeToArray(r, a) {
(null == a || a > r.length) && (a = r.length);
for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
return n;
}
function _arrayWithoutHoles(r) {
if (Array.isArray(r)) return _arrayLikeToArray(r);
}
function _assertClassBrand(e, t, n) {
if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n;
throw new TypeError("Private element is not present on this object");
}
function _checkPrivateRedeclaration(e, t) {
if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object");
}
function _classCallCheck(a, n) {
if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function");
}
function _classPrivateFieldGet2(s, a) {
return s.get(_assertClassBrand(s, a));
}
function _classPrivateFieldInitSpec(e, t, a) {
_checkPrivateRedeclaration(e, t), t.set(e, a);
}
function _classPrivateFieldSet2(s, a, r) {
return s.set(_assertClassBrand(s, a), r), r;
}
function _defineProperties(e, r) {
for (var t = 0; t < r.length; t++) {
var o = r[t];
o.enumerable = o.enumerable || false, o.configurable = true, "value" in o && (o.writable = true), Object.defineProperty(e, _toPropertyKey(o.key), o);
}
}
function _createClass(e, r, t) {
return r && _defineProperties(e.prototype, r), Object.defineProperty(e, "prototype", {
writable: false
}), e;
}
function _iterableToArray(r) {
if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r);
}
function _nonIterableSpread() {
throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
}
function _toConsumableArray(r) {
return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread();
}
function _toPrimitive(t, r) {
if ("object" != typeof t || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r);
if ("object" != typeof i) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return (String )(t);
}
function _toPropertyKey(t) {
var i = _toPrimitive(t, "string");
return "symbol" == typeof i ? i : i + "";
}
function _unsupportedIterableToArray(r, a) {
if (r) {
if ("string" == typeof r) return _arrayLikeToArray(r, a);
var t = {}.toString.call(r).slice(8, -1);
return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
}
}
var ENTROPY = 123; // Raise numbers to prevent collisions in lower indexes
var int2HexColor = function int2HexColor(num) {
return "#".concat(Math.min(num, Math.pow(2, 24)).toString(16).padStart(6, '0'));
};
var rgb2Int = function rgb2Int(r, g, b) {
return (r << 16) + (g << 8) + b;
};
var colorStr2Int = function colorStr2Int(str) {
var _tinyColor$toRgb = tinyColor(str).toRgb(),
r = _tinyColor$toRgb.r,
g = _tinyColor$toRgb.g,
b = _tinyColor$toRgb.b;
return rgb2Int(r, g, b);
};
var checksum = function checksum(n, csBits) {
return n * ENTROPY % Math.pow(2, csBits);
};
var _registry = /*#__PURE__*/new WeakMap();
var _csBits = /*#__PURE__*/new WeakMap();
var _default = /*#__PURE__*/function () {
function _default() {
var csBits = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 6;
_classCallCheck(this, _default);
// Internal state
_classPrivateFieldInitSpec(this, _registry, void 0);
// indexed objects for rgb lookup;
_classPrivateFieldInitSpec(this, _csBits, void 0);
_classPrivateFieldSet2(_csBits, this, csBits);
this.reset();
}
return _createClass(_default, [{
key: "reset",
value: function reset() {
_classPrivateFieldSet2(_registry, this, ['__reserved for background__']);
}
}, {
key: "register",
value: function register(obj) {
if (_classPrivateFieldGet2(_registry, this).length >= Math.pow(2, 24 - _classPrivateFieldGet2(_csBits, this))) {
// color has 24 bits (-checksum)
return null; // Registry is full
}
var idx = _classPrivateFieldGet2(_registry, this).length;
var cs = checksum(idx, _classPrivateFieldGet2(_csBits, this));
var color = int2HexColor(idx + (cs << 24 - _classPrivateFieldGet2(_csBits, this)));
_classPrivateFieldGet2(_registry, this).push(obj);
return color;
}
}, {
key: "lookup",
value: function lookup(color) {
if (!color) return null; // invalid color
var n = typeof color === 'string' ? colorStr2Int(color) : rgb2Int.apply(void 0, _toConsumableArray(color));
if (!n) return null; // 0 index is reserved for background
var idx = n & Math.pow(2, 24 - _classPrivateFieldGet2(_csBits, this)) - 1; // registry index
var cs = n >> 24 - _classPrivateFieldGet2(_csBits, this) & Math.pow(2, _classPrivateFieldGet2(_csBits, this)) - 1; // extract bits reserved for checksum
if (checksum(idx, _classPrivateFieldGet2(_csBits, this)) !== cs || idx >= _classPrivateFieldGet2(_registry, this).length) return null; // failed checksum or registry out of bounds
return _classPrivateFieldGet2(_registry, this)[idx];
} // How many bits to reserve for checksum. Will eat away into the usable size of the registry.
}]);
}();
export { _default as default };

View File

@@ -0,0 +1,86 @@
<head>
<style>
body {
margin: 0;
}
#tooltip {
position: absolute;
transform: translate(-50%, 25px);
font-family: Sans-serif;
font-size: 12px;
padding: 3px;
border-radius: 3px;
color: lavender;
background: midnightblue;
opacity: 0.7;
visibility: hidden; /* by default */
}
</style>
<script src="//cdn.jsdelivr.net/npm/canvas-color-tracker"></script>
<!--<script src="../dist/canvas-color-tracker.js"></script>-->
<script src="circle-generator.js"></script>
</head>
<body>
<canvas id="my-canvas"></canvas>
<div id="tooltip"></div>
<script>
const NUM_CIRCLES = 1e6;
const colorTracker = new ColorTracker(4); // Reduce checksum to support 2^20 (~1M) objects
const tooltip = document.getElementById('tooltip');
const canvas = document.getElementById('my-canvas');
const shadowCanvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const shadowCtx = shadowCanvas.getContext('2d', { willReadFrequently: true });
// size canvases
[canvas, shadowCanvas].forEach(cv => {
cv.width = window.innerWidth;
cv.height = window.innerHeight;
});
const circles = genCircles(canvas.width, canvas.height, NUM_CIRCLES);
circles.forEach(circle => {
// Render in main canvas
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.r, 0, 2 * Math.PI, false);
ctx.fillStyle = 'rgba(197, 42, 27, 0.6)';
ctx.fill();
// Register in colorTracker
const shadowColor = colorTracker.register(circle);
if (!shadowColor) return; // registry is full
// Paint in shadow canvas (not bound to DOM)
shadowCtx.beginPath();
shadowCtx.arc(circle.x, circle.y, circle.r, 0, 2 * Math.PI, false);
shadowCtx.fillStyle = shadowColor;
shadowCtx.fill();
});
canvas.addEventListener('mousemove', ev => {
const mousePos = { offsetX: x, offsetY: y } = ev;
// Move tooltip
tooltip.style.left = `${mousePos.x}px`;
tooltip.style.top = `${mousePos.y}px`;
// Get px color of mouse position from shadow canvas
const pxColor = shadowCtx.getImageData(mousePos.x, mousePos.y, 1, 1).data;
// Retrieve original object by px color
const hoverObj = colorTracker.lookup(pxColor);
tooltip.style.visibility = hoverObj ? 'visible' : 'hidden';
tooltip.innerHTML = hoverObj
? `Circle id: ${hoverObj.id}<br>Center: ${hoverObj.x},${hoverObj.y}<br>Radius: ${hoverObj.r}`
: ''; // no object found
});
</script>
</body>

View File

@@ -0,0 +1,86 @@
<head>
<style>
body {
margin: 0;
}
#tooltip {
position: absolute;
transform: translate(-50%, 25px);
font-family: Sans-serif;
font-size: 12px;
padding: 3px;
border-radius: 3px;
color: lavender;
background: midnightblue;
opacity: 0.7;
visibility: hidden; /* by default */
}
</style>
<script src="//cdn.jsdelivr.net/npm/canvas-color-tracker"></script>
<!--<script src="../dist/canvas-color-tracker.js"></script>-->
<script src="circle-generator.js"></script>
</head>
<body>
<canvas id="my-canvas"></canvas>
<div id="tooltip"></div>
<script>
const NUM_CIRCLES = 1e4;
const colorTracker = new ColorTracker();
const tooltip = document.getElementById('tooltip');
const canvas = document.getElementById('my-canvas');
const shadowCanvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const shadowCtx = shadowCanvas.getContext('2d', { willReadFrequently: true });
// size canvases
[canvas, shadowCanvas].forEach(cv => {
cv.width = window.innerWidth;
cv.height = window.innerHeight;
});
const circles = genCircles(canvas.width, canvas.height, NUM_CIRCLES);
circles.forEach(circle => {
// Render in main canvas
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.r, 0, 2 * Math.PI, false);
ctx.fillStyle = 'rgba(197, 42, 27, 0.6)';
ctx.fill();
// Register in colorTracker
const shadowColor = colorTracker.register(circle);
if (!shadowColor) return; // registry is full
// Paint in shadow canvas (not bound to DOM)
shadowCtx.beginPath();
shadowCtx.arc(circle.x, circle.y, circle.r, 0, 2 * Math.PI, false);
shadowCtx.fillStyle = shadowColor;
shadowCtx.fill();
});
canvas.addEventListener('mousemove', ev => {
const mousePos = { offsetX: x, offsetY: y } = ev;
// Move tooltip
tooltip.style.left = `${mousePos.x}px`;
tooltip.style.top = `${mousePos.y}px`;
// Get px color of mouse position from shadow canvas
const pxColor = shadowCtx.getImageData(mousePos.x, mousePos.y, 1, 1).data;
// Retrieve original object by px color
const hoverObj = colorTracker.lookup(pxColor);
tooltip.style.visibility = hoverObj ? 'visible' : 'hidden';
tooltip.innerHTML = hoverObj
? `Circle id: ${hoverObj.id}<br>Center: ${hoverObj.x},${hoverObj.y}<br>Radius: ${hoverObj.r}`
: ''; // no object found
});
</script>
</body>

View File

@@ -0,0 +1,86 @@
<head>
<style>
body {
margin: 0;
}
#tooltip {
position: absolute;
transform: translate(-50%, 25px);
font-family: Sans-serif;
font-size: 12px;
padding: 3px;
border-radius: 3px;
color: lavender;
background: midnightblue;
opacity: 0.7;
visibility: hidden; /* by default */
}
</style>
<script src="//cdn.jsdelivr.net/npm/canvas-color-tracker"></script>
<!--<script src="../dist/canvas-color-tracker.js"></script>-->
<script src="circle-generator.js"></script>
</head>
<body>
<canvas id="my-canvas"></canvas>
<div id="tooltip"></div>
<script>
const NUM_CIRCLES = 1e2;
const colorTracker = new ColorTracker();
const tooltip = document.getElementById('tooltip');
const canvas = document.getElementById('my-canvas');
const shadowCanvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const shadowCtx = shadowCanvas.getContext('2d', { willReadFrequently: true });
// size canvases
[canvas, shadowCanvas].forEach(cv => {
cv.width = window.innerWidth;
cv.height = window.innerHeight;
});
const circles = genCircles(canvas.width, canvas.height, NUM_CIRCLES);
circles.forEach(circle => {
// Render in main canvas
ctx.beginPath();
ctx.arc(circle.x, circle.y, circle.r, 0, 2 * Math.PI, false);
ctx.fillStyle = 'rgba(197, 42, 27, 0.6)';
ctx.fill();
// Register in colorTracker
const shadowColor = colorTracker.register(circle);
if (!shadowColor) return; // registry is full
// Paint in shadow canvas (not bound to DOM)
shadowCtx.beginPath();
shadowCtx.arc(circle.x, circle.y, circle.r, 0, 2 * Math.PI, false);
shadowCtx.fillStyle = shadowColor;
shadowCtx.fill();
});
canvas.addEventListener('mousemove', ev => {
const mousePos = { offsetX: x, offsetY: y } = ev;
// Move tooltip
tooltip.style.left = `${mousePos.x}px`;
tooltip.style.top = `${mousePos.y}px`;
// Get px color of mouse position from shadow canvas
const pxColor = shadowCtx.getImageData(mousePos.x, mousePos.y, 1, 1).data;
// Retrieve original object by px color
const hoverObj = colorTracker.lookup(pxColor);
tooltip.style.visibility = hoverObj ? 'visible' : 'hidden';
tooltip.innerHTML = hoverObj
? `Circle id: ${hoverObj.id}<br>Center: ${hoverObj.x},${hoverObj.y}<br>Radius: ${hoverObj.r}`
: ''; // no object found
});
</script>
</body>

View File

@@ -0,0 +1,12 @@
// Generate random circles
function genCircles(width, height, N = 500) {
const minR = 1;
const maxR = Math.sqrt(width * height / N) * 0.5;
return [...Array(N)].map((_, idx) => ({
id: idx,
x: Math.round(Math.random() * width),
y: Math.round(Math.random() * height),
r: Math.max(minR, Math.round(Math.random() * maxR))
}));
}

View File

@@ -0,0 +1,65 @@
{
"name": "canvas-color-tracker",
"version": "1.3.2",
"description": "A utility to track objects on a canvas by unique px color",
"type": "module",
"jsdelivr": "dist/canvas-color-tracker.min.js",
"unpkg": "dist/canvas-color-tracker.min.js",
"main": "dist/canvas-color-tracker.mjs",
"module": "dist/canvas-color-tracker.mjs",
"types": "dist/canvas-color-tracker.d.ts",
"exports": {
"types": "./dist/canvas-color-tracker.d.ts",
"umd": "./dist/canvas-color-tracker.min.js",
"default": "./dist/canvas-color-tracker.mjs"
},
"sideEffects": false,
"repository": {
"type": "git",
"url": "git+https://github.com/vasturiano/canvas-color-tracker.git"
},
"keywords": [
"canvas",
"color",
"tracker",
"interaction",
"hover",
"hidden"
],
"author": {
"name": "Vasco Asturiano",
"url": "https://github.com/vasturiano"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/vasturiano/canvas-color-tracker/issues"
},
"homepage": "https://github.com/vasturiano/canvas-color-tracker",
"files": [
"dist/**/*",
"example/**/*"
],
"scripts": {
"build": "rimraf dist && rollup -c",
"dev": "rollup -w -c",
"prepare": "npm run build"
},
"dependencies": {
"tinycolor2": "^1.6.0"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@babel/preset-env": "^7.26.9",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"rimraf": "^6.0.1",
"rollup": "^4.36.0",
"rollup-plugin-dts": "^6.2.1",
"typescript": "^5.8.2"
},
"engines": {
"node": ">=12"
}
}