CO2 emissions by country rendered on a globe.
## Using Domeleon Domeleon is a compact abstraction over any virtual DOM, including Preact (the default), React, and Vue. A Domeleon Component is a class representing application state rather than a wrapper around a DOM element. Components may define a pure view function of their state, and compose into hierarchies that can be updated, serialized, validated, or routed. ### Example **HTML** ```html <div id="app"></div> ``` **Code** ```ts import { App, Component, div, button, formField, inputRange, canvas } from "domeleon" import { inputNumber } from "domeleon/maskito" class Counter extends Component { count: number role: string #canvasEl: HTMLCanvasElement #canvasCtx: CanvasRenderingContext2D constructor(initial: number, role: string) { super() this.count = initial this.role = role } view() { return div({ class: "counter" }, formField ({ // databound input target: this, inputFn: inputRange, // inputSelect, inputText, inputTextArea, inputCheckbox etc. prop: () => this.count, label: this.role, fieldAttrs: { class: "field-class"}, labelAttrs: { class: "label-class"}, inputProps: { attrs: { min: 100, max: 250, step: 1 } } }), button({ onClick: () => this.inc(+1) }, "+"), // always use closure to bind to preserve "this" button({ onClick: () => this.inc(-1) }, "-"), this.count, canvas({ width: 400, height: 20, onMounted: el => { this.#canvasEl = el as HTMLCanvasElement this.#canvasCtx = this.#canvasEl.getContext("2d") return () => { // dispose this.#canvasEl = undefined this.#canvasCtx = undefined } } }) ) } inc(x: number) { this.count+=x this.update() // triggers render on app } // `onRendered` called after VDOM has created/updated DOM elements // useful for operations that require raw DOM access, such as canvas operations override onRendered () { this.drawOnCanvas() } drawOnCanvas() { const canvas = this.#canvasEl const cc = this.#canvasCtx cc.clearRect(0, 0, canvas.width, canvas.height) const barWidth = (this.count / 250) * canvas.width cc.fillStyle = "#4a90e2" cc.fillRect(0, 0, barWidth, canvas.height) } } class Game extends Component { wins = new Counter(10, "wins") losses = new Counter(5, "losses") view() { return div( this.wins.view(), this.losses.view() ) } } new App({ root: new Game(), id: "app"}) ```
import { App, Component, canvas, div } from "domeleon"
import {
Scene, PerspectiveCamera, WebGLRenderer, SphereGeometry, Mesh, MeshPhongMaterial,
MeshLambertMaterial, Vector3, TextureLoader, AmbientLight, DirectionalLight,
CylinderGeometry, MeshBasicMaterial, Group
} from "three";
const EARTH_TEXTURE = 'https://unpkg.com/[email protected]/example/img/earth-blue-marble.jpg';
const EARTH_BUMP = 'https://unpkg.com/[email protected]/example/img/earth-topology.png';
const CO2_EMISSIONS: [string, number, number, number][] = tb.data(0)
.split("\n")
.map(line => {
const [name, lat, lon, val] = line.split(",");
return [name, parseFloat(lat), parseFloat(lon), parseFloat(val)];
});
const TOTAL_EMISSIONS = Math.round(CO2_EMISSIONS.reduce((acc, curr) => acc + curr[3], 0) / 1000);
const latLonToVector3 = (lat: number, lon: number, r: number) => {
const phi = (90 - lat) * Math.PI / 180, theta = (lon + 180) * Math.PI / 180;
return new Vector3(-r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta));
};
interface Particle { mesh: Mesh; life: number; maxLife: number; startPos: Vector3; dir: Vector3; speed: number; offset: Vector3 }
interface Chimney { pos: Vector3; dir: Vector3; height: number; emissions: number; radius: number }
class Earth extends Component {
private scene: Scene;
private camera: PerspectiveCamera;
private renderer: WebGLRenderer;
private earthGroup: Group;
private isDragging = false;
private prevPos = { x: 0, y: 0 };
private rotationVelocity = { x: 0, y: 0 };
private lastDragTime = 0;
private animId?: number;
private particles: Particle[] = [];
private chimneys: Chimney[] = [];
private lastTime = performance.now();
view() {
return div(
div({ class: "co2-label" }, `${TOTAL_EMISSIONS} GT CO2 / year`),
canvas({
onMounted: (el) => {
return this.setup(el as HTMLCanvasElement);
}
})
);
}
private setup(canvas: HTMLCanvasElement) {
this.scene = new Scene();
this.camera = new PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 1000);
this.camera.position.z = 3;
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true });
this.renderer.setSize(innerWidth, innerHeight);
this.renderer.setPixelRatio(devicePixelRatio);
const loader = new TextureLoader();
this.earthGroup = new Group();
this.earthGroup.add(new Mesh(
new SphereGeometry(1, 64, 64),
new MeshPhongMaterial({ map: loader.load(EARTH_TEXTURE), bumpMap: loader.load(EARTH_BUMP), bumpScale: 0.05 })
));
const maxEmissions = Math.max(...CO2_EMISSIONS.map(c => c[3]));
CO2_EMISSIONS.forEach(([_, lat, lon, emissions]) => {
const norm = emissions / maxEmissions, h = 0.25 * Math.cbrt(norm), r = h * 0.2;
const pos = latLonToVector3(lat, lon, 1), dir = pos.clone().normalize();
const geo = new CylinderGeometry(r, r, h, 16);
geo.translate(0, h / 2, 0); geo.rotateX(Math.PI / 2);
const mesh = new Mesh(geo, new MeshLambertMaterial({ color: 0xff6600, emissive: 0xff4400, emissiveIntensity: 0.3 }));
mesh.position.copy(pos); mesh.lookAt(pos.clone().multiplyScalar(2));
this.earthGroup.add(mesh);
this.chimneys.push({ pos, dir, height: h, emissions: norm, radius: r });
});
this.chimneys.forEach(c => { for (let i = 0; i < 8; i++) this.createParticle(c, i / 10); });
this.scene.add(this.earthGroup);
this.scene.add(new AmbientLight(0xffffff, 0.4));
const light = new DirectionalLight(0xffffff, 1); light.position.set(5, 3, 5); this.scene.add(light);
this.updateScale();
this.animate();
const getPointer = (e: MouseEvent | TouchEvent) =>
'touches' in e ? { x: e.touches[0]?.clientX ?? 0, y: e.touches[0]?.clientY ?? 0 } : { x: e.clientX, y: e.clientY };
const onStart = (e: MouseEvent | TouchEvent) => {
this.isDragging = true;
this.prevPos = getPointer(e);
this.rotationVelocity = { x: 0, y: 0 };
this.lastDragTime = performance.now();
};
const onMove = (e: MouseEvent | TouchEvent) => {
const pos = getPointer(e);
if (this.isDragging) {
const now = performance.now();
const dx = (pos.x - this.prevPos.x) * 0.005;
const dy = (pos.y - this.prevPos.y) * 0.005;
this.earthGroup.rotation.y += dx;
this.earthGroup.rotation.x += dy;
const dt = Math.max(now - this.lastDragTime, 1);
this.rotationVelocity = { x: dy / dt * 16, y: dx / dt * 16 };
this.lastDragTime = now;
if ('touches' in e) e.preventDefault();
}
this.prevPos = pos;
};
const onEnd = () => { this.isDragging = false; };
const onResize = () => {
this.camera.aspect = innerWidth / innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(innerWidth, innerHeight);
this.updateScale();
};
addEventListener('mousedown', onStart);
addEventListener('mouseup', onEnd);
addEventListener('mousemove', onMove);
addEventListener('touchstart', onStart, { passive: false });
addEventListener('touchmove', onMove, { passive: false });
addEventListener('touchend', onEnd);
addEventListener('resize', onResize);
return () => {
if (this.animId) cancelAnimationFrame(this.animId);
removeEventListener('mousedown', onStart);
removeEventListener('mouseup', onEnd);
removeEventListener('mousemove', onMove);
removeEventListener('touchstart', onStart);
removeEventListener('touchmove', onMove);
removeEventListener('touchend', onEnd);
removeEventListener('resize', onResize);
}
}
private createParticle(c: Chimney, initLife = 0) {
const size = 0.015 * Math.cbrt(c.emissions), maxLife = 2 + Math.random() * 1.5;
const offset = new Vector3((Math.random() - 0.5) * c.radius * 1.5, (Math.random() - 0.5) * c.radius * 1.5, (Math.random() - 0.5) * c.radius * 1.5);
const startPos = c.pos.clone().add(c.dir.clone().multiplyScalar(c.height));
const mesh = new Mesh(new SphereGeometry(size, 6, 6), new MeshBasicMaterial({ color: 0x999999, transparent: true, opacity: 0.6, depthWrite: false }));
mesh.position.copy(startPos).add(offset);
this.earthGroup.add(mesh);
this.particles.push({ mesh, life: initLife * maxLife, maxLife, startPos, dir: c.dir.clone(), speed: 0.02 + Math.sqrt(c.emissions) * 0.05, offset });
}
private updateParticles(dt: number) {
this.particles.forEach(p => {
p.life += dt;
if (p.life >= p.maxLife) {
p.life = 0; p.mesh.position.copy(p.startPos).add(p.offset);
p.mesh.scale.set(1, 1, 1); (p.mesh.material as MeshBasicMaterial).opacity = 0.6;
return;
}
const prog = p.life / p.maxLife;
const move = p.dir.clone().multiplyScalar(p.speed * dt);
move.x += (Math.random() - 0.5) * 0.002; move.y += (Math.random() - 0.5) * 0.002; move.z += (Math.random() - 0.5) * 0.002;
p.mesh.position.add(move);
const s = 1 + prog * 2; p.mesh.scale.set(s, s, s);
(p.mesh.material as MeshBasicMaterial).opacity = 0.6 * (1 - prog * prog);
});
}
private updateScale() {
const fov = this.camera.fov * Math.PI / 180;
const h = 2 * Math.tan(fov / 2) * this.camera.position.z;
const s = Math.min(h, h * this.camera.aspect) * 0.9 / 2;
this.earthGroup.scale.set(s, s, s);
}
private animate = () => {
this.animId = requestAnimationFrame(this.animate);
const now = performance.now(), dt = (now - this.lastTime) / 1000;
this.lastTime = now;
if (!this.isDragging) {
// Apply momentum/auto-rotation
this.earthGroup.rotation.y += 0.001 + this.rotationVelocity.y;
this.earthGroup.rotation.x += this.rotationVelocity.x;
// Damping
this.rotationVelocity.x *= 0.95;
this.rotationVelocity.y *= 0.95;
}
this.updateParticles(dt);
this.renderer.render(this.scene, this.camera);
}
}
new App({ root: new Earth(), id: "app" })