---
format: typebulb/v1
name: Earth
---

**code.tsx**

```tsx
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/three-globe@2.31.1/example/img/earth-blue-marble.jpg';
const EARTH_BUMP = 'https://unpkg.com/three-globe@2.31.1/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" })

```
**styles.css**

```css
html[data-theme="dark"] {
  --bg-gradient-start: #0a0a1a;
  --bg-gradient-end: #000;
  --label-color: #9ca3af;
}

html[data-theme="light"] {
  --bg-gradient-start: #87ceeb;
  --bg-gradient-end: #1a4d80;
  --label-color: #1a1a1a;
}

body {
  margin: 0;
  overflow: hidden;
  background: radial-gradient(circle at center, var(--bg-gradient-start) 50%, var(--bg-gradient-end) 100%);
}

.co2-label {
  position: fixed;
  top: 20px;
  left: 20px;
  font-family: 'Courier New', 'Menlo', 'Monaco', 'Consolas', monospace;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 0.05em;
  color: var(--label-color);
  z-index: 10;
  pointer-events: none;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  opacity: 0.9;
}
```
**index.html**

```html
<div id="app"></div>
```
**data.txt**

```txt
China,32,106,13259.64
United States of America,40,-97,4682.04
India,23,79,2955.18
Russia,58,45,2069.5
Japan,36,138,944.76
Iran,32,55,778.8
Indonesia,-1,102,674.54
Saudi Arabia,24,45,622.91
Germany,51,10,582.95
Canada,60,-102,575.01
South Korea,36,128,573.54
Mexico,24,-102,487.09
Brazil,-12,-50,479.5
Turkey,39,35,438.32
South Africa,-30,24,397.37
Australia,-24,134,373.62
Vietnam,22,105,372.95
San Marino,44,12,305.49
United Kingdom,54,-2,302.1
Poland,52,19,286.91
Malaysia,3,114,283.32
Monaco,44,7,282.43
Taiwan,24,121,279.85
Thailand,15,101,274.16
Egypt,26,29,249.33
Kazakhstan,49,69,239.87
Spain,40,-3,217.26
United Arab Emirates,23,55,205.99
Pakistan,29,69,200.51
Iraq,33,43,192.91
Argentina,-34,-64,183.78
Algeria,27,3,180.36
Philippines,11,122,161.29
Uzbekistan,42,64,137.9
Ukraine,50,32,136.2
Nigeria,9,8,127.94
Qatar,25,51,127.91
Bangladesh,24,90,124.79
Netherlands,52,6,122.87
Kuwait,29,47,111.63
Colombia,3,-73,100.86
Oman,22,57,93.09
Czechia,50,15,90.51
Venezuela,7,-65,84.6
Belgium,51,5,84.31
Chile,-38,-72,84
Romania,46,25,70.77
Morocco,32,-7,69.86
Turkmenistan,40,59,65.99
North Korea,40,126,64.27
Libya,27,18,61.26
Israel,31,35,61.25
Austria,48,14,58.82
Peru,-13,-73,58.4
Singapore,1,104,57.07
Serbia,44,21,56.12
Belarus,54,28,54.18
Greece,39,22,51.67
Ecuador,-1,-78,45.33
Norway,61,10,44.07
Hungary,47,19,43.83
Azerbaijan,40,47,42.77
Bulgaria,43,25,39.79
Bahrain,26,51,37.43
Portugal,40,-8,36.17
New Zealand,-40,173,35.8
Sweden,66,19,35.39
Slovakia,49,19,34.86
Hong Kong,22,114,34.67
Switzerland,47,7,34.22
Myanmar,22,96,33.37
Ireland,53,-8,32.48
Finland,63,27,32.27
Tunisia,34,9,31.5
Dominican Rep.,19,-71,31.35
Angola,-12,18,28.23
Mongolia,46,104,28.12
Trinidad and Tobago,11,-61,27.22
Denmark,56,9,26.77
Laos,19,103,26.02
Syria,35,38,25.59
Ghana,8,-1,24.16
Bolivia,-17,-65,23.81
Jordan,31,36,23.58
Cuba,21,-78,22.07
Bosnia and Herz.,44,18,22
Kenya,1,38,21.73
Guatemala,15,-90,21.35
Sudan,16,29,21.27
Sri Lanka,8,81,20.52
Tanzania,-6,35,19.37
Cambodia,13,105,17.97
Nepal,28,84,17.93
Croatia,46,16,17.46
Lebanon,34,36,17.33
Ethiopia,8,39,16.71
Panama,9,-80,14.72
Côte d'Ivoire,7,-6,14.41
Puerto Rico,18,-66,13.82
Lithuania,55,24,13.11
Georgia,42,44,12.86
Slovenia,46,15,12.08
Senegal,15,-15,12.02
Zimbabwe,-19,30,11.74
Estonia,59,26,11.44
Honduras,15,-87,10.95
Yemen,15,46,10.9
Cameroon,5,12,10.76
Kyrgyzstan,42,75,10.46
Moldova,47,28,9.93
Mozambique,-14,38,9.74
Brunei,4,115,9.72
Tajikistan,38,73,9.31
Uruguay,-33,-56,8.82
North Macedonia,42,22,8.76
Afghanistan,34,66,8.71
Costa Rica,10,-84,8.57
El Salvador,14,-89,8.38
Paraguay,-22,-60,8.25
Zambia,-15,26,8.06
Armenia,40,45,7.73
Botswana,-22,24,7.42
Congo,0,16,7.25
Uganda,2,33,7.22
Cyprus,35,33,7.18
Luxembourg,50,6,7.01
Jamaica,18,-77,6.86
Mali,19,-2,6.66
Latvia,57,25,6.55
Malawi,-13,34,6.45
Benin,10,2,6.44
New Caledonia,-21,165,6.21
Burkina Faso,13,-1,6
Papua New Guinea,-6,144,5.95
Nicaragua,13,-85,5.73
Gabon,0,12,4.93
Mauritania,20,-10,4.65
Albania,41,20,4.59
Namibia,-21,17,4.36
Mauritius,-20,58,4.21
Madagascar,-19,47,4.1
Dem. Rep. Congo,-2,23,3.8
Guinea,11,-10,3.78
Guinea,11,-10,3.72
Haiti,19,-72,3.54
Guyana,5,-59,3.3
Iceland,65,-19,3.09
Macao,22,114,3.01
Maldives,4,74,2.88
Niger,17,10,2.82
Suriname,4,-56,2.63
Chad,15,19,2.57
Togo,9,1,2.49
Curaçao,12,-69,2.43
Fiji,-18,178,2.21
Bhutan,28,90,1.99
Bahamas,26,-77,1.68
Malta,36,14,1.68
Rwanda,-2,30,1.65
Liberia,6,-9,1.64
Palau,8,135,1.44
eSwatini,-27,31,1.39
Seychelles,-5,55,1.24
Sierra Leone,9,-12,1.07
Cabo Verde,15,-24,1.01
Lesotho,-29,28,0.88
Somalia,4,45,0.87
Burundi,-3,30,0.84
Barbados,13,-60,0.8
Djibouti,12,42,0.75
Timor-Leste,-9,126,0.7
Eritrea,16,38,0.67
Gambia,14,-15,0.61
Greenland,74,-39,0.58
Aruba,13,-70,0.53
Samoa,-14,-172,0.47
Solomon Is.,-8,159,0.42
Central African Rep.,7,21,0.37
Cayman Is.,19,-81,0.36
Bermuda,32,-65,0.35
Guinea-Bissau,12,-15,0.35
Antigua and Barb.,17,-62,0.32
Comoros,-12,43,0.32
Saint Lucia,14,-61,0.3
Vanuatu,-15,167,0.29
Belize,17,-89,0.28
W. Sahara,24,-13,0.26
Tonga,-21,-175,0.22
São Tomé and Principe,1,7,0.21
Cook Is.,-21,-160,0.14
Grenada,12,-62,0.14
St. Kitts and Nevis,17,-63,0.12
Kiribati,2,-157,0.1
St. Vin. and Gren.,13,-61,0.1
Turks and Caicos Is.,22,-72,0.1
British Virgin Is.,18,-65,0.08
Dominica,15,-61,0.08
St. Pierre and Miquelon,47,-56,0.04
Anguilla,18,-63,0.02
Falkland Is.,-52,-59,0.02
Saint Helena,-16,-6,0.02
```
**config.json**

```json
{
  "dependencies": {
    "three": "0.183.2",
    "domeleon": "^0.5.2"
  },
  "description": "CO2 emissions by country rendered on a globe."
}
```
**notes.md**

````md
## 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"})
```
````