Gravity Dots: Wenn Daten Gewicht bekommen

Wenn du auf unserer Homepage landest, siehst du als Erstes ein welliges Terrain aus leuchtenden Punkten — eine prozedurale Landschaft, die sich lebendig anfühlt. Manche Punkte pulsieren heller als andere, steigen kurz über die Oberfläche auf und sinken wieder zurück. Es sieht aus wie etwas Erdachtes. Ist es nicht. Jedes Beacon repräsentiert eine echte Kundenbeziehung, und die Daten dahinter stammen von dort, wo man sie am wenigsten erwartet: unserem Zeiterfassungstool.

Das ist die Geschichte, wie Tabellendaten ihre Schwerkraft fanden.

Die Idee

Jede Agentur-Website braucht einen Hero-Bereich. Die meisten greifen zu einem Stockvideo oder einem Partikeleffekt. Wir wollten, dass unserer ein Geheimnis birgt — dass das, was man sieht, echt ist, auch wenn es sich nicht so anfühlen sollte.

Wir wollten keine Logo-Wand oder einen Zähler, der auf „500+ Projekte” hochtickert. Wir wollten etwas, das man spüren kann. Ein Terrain, in dem sich Jahre der Arbeit in die Ferne erstrecken, mit Lichtpunkten dort, wo wir echte Zeit und Energie investiert haben. Etwas, bei dem man kurz innehält und denkt: „Moment — sind das tatsächlich echte Daten?”

Ja, sind es. Und die Entwicklung war eines dieser Projekte, bei denen jede technische Einschränkung eine Tür zu etwas Unerwartetem geöffnet hat.

Die Metapher ist einfach: In einem Meer aus Möglichkeiten ist unser Einfluss sichtbar.

Die Daten-Pipeline

Die Visualisierung beginnt nicht mit Shadern oder Three.js, sondern mit einem Node-Skript und der Toggl-API.

Schritt 1: Zeitdaten ernten

Toggls Reporting-API limitiert Abfragen auf Ein-Jahres-Fenster. Also gehen wir von heute aus in Jahresschritten rückwärts bis 2017 — unserem Gründungsjahr —, sammeln Projektzusammenfassungen aus jedem Fenster und mergen sie anhand der Projekt-ID.

// Sliding 1-year windows from now back to 2015
let cursor = now;
while (cursor > earliest) {
  const windowStart = new Date(cursor);
  windowStart.setFullYear(windowStart.getFullYear() - 1);
  windows.push({ start: toDateStr(windowStart), end: toDateStr(cursor) });
  cursor = new Date(windowStart);
}

Drei API-Aufrufe laufen parallel: Projektzusammenfassungen (die Stunden), die Projektliste (die Projekte auf Kunden abbildet) und die Kundenliste (die uns die Namen liefert).

Schritt 2: Aggregation nach Kunde

Einzelne Projekte sind interessant, aber uns geht es um Beziehungen. Ein einzelner Kunde kann fünf Projekte über drei Jahre haben. Wir aggregieren die gesamte erfasste Zeit pro Kunde und finden das früheste Projekt-Erstellungsdatum — das wird zum „Startdatum” des Kunden in unserer Timeline.

// project → client → aggregate
for (const entry of summary) {
  const clientId = projectClientMap.get(entry.project_id);
  const clientName = clientMap.get(clientId);

  client.tracked += entry.tracked_seconds;
  // Keep the earliest project start date
  if (date < client.startDate) client.startDate = date;
}

Interne Projekte werden herausgefiltert. Was bleibt, ist ein sauberer Datensatz: 16 Kundeneinträge, jeweils mit Startdatum und erfassten Gesamtminuten.

Schritt 3: Das Ergebnis

Die Pipeline erzeugt eine minimale JSON-Datei — keine Namen, keine Projektdetails, nur die Form jeder Beziehung:

[
  { "startDate": "2017-05-09", "totalTime": 4099 },
  { "startDate": "2022-06-27", "totalTime": 86096 },
  ...
]

Privacy by Design. Die Daten zeigen die Textur unserer Arbeitsgeschichte, ohne preiszugeben, wer oder was dahintersteckt. Man kann sehen, dass wir seit Mitte 2022 ein langes, intensives Engagement haben und 2024 ein Cluster neuer Beziehungen entstanden ist — aber die Namen erfährt man nie. Diese Spannung hat uns gefallen: echte Daten, null Offenlegung.

Von Daten zum Terrain

Die JSON-Datei fließt direkt in eine Three.js-Komponente. Jeder Kunde wird zu einem Beacon, positioniert auf einer prozeduralen Landschaft:

Die logarithmische Skala ist entscheidend. Ohne sie würden ein oder zwei große Kunden dominieren und der Rest wäre unsichtbar. Mit ihr hat jede Beziehung sichtbare Präsenz, während die größeren trotzdem klar hervorstechen.

Probier es selbst aus — ändere das Startdatum, um das Beacon über die Landschaft zu bewegen, und passe die Gesamtzeit an, um zu sehen, wie die logarithmische Skalierung Höhe und Größe beeinflusst. Ein 100-Stunden-Projekt erhebt sich kaum über die Oberfläche; ein 1.400-Stunden-Engagement ragt darüber hinaus.

Die Animation

Die Beacons pulsieren nicht alle gleichzeitig. Sie wechseln sich ab — eines steigt über 3 Sekunden entlang einer Sinuskurve auf, pausiert 1,5 Sekunden, dann beginnt das nächste. Das erzeugt einen Rhythmus, wie Planeten auf versetzten Umlaufbahnen. Das Terrain selbst verschiebt sich langsam durch Perlin-Noise — zwei überlagerte Gitter, die mit unterschiedlichen Geschwindigkeiten driften und so Tiefe erzeugen.

Das Terrain

Zwei Schichten aus Point Clouds, gerendert mit eigenen GLSL-Shadern. Die Höhe jedes Punkts wird komplett auf der GPU berechnet — mit Fractional Brownian Motion, also mehreren Oktaven von Perlin-Noise übereinandergeschichtet. Hier ist der Kern des Vertex-Shaders:

float hash2(float x, float y) {
  float n = sin(x * 127.1 + y * 311.7) * 43758.5453;
  return fract(n);
}

float vnoise2(float x, float y) {
  float xi = floor(x), yi = floor(y);
  float xf = x - xi, yf = y - yi;
  // Quintic Hermite curve — smoother than cubic, no visible grid artifacts
  float fx = xf*xf*xf*(xf*(xf*6.0-15.0)+10.0);
  float fy = yf*yf*yf*(yf*(yf*6.0-15.0)+10.0);
  return mix(
    mix(hash2(xi,yi), hash2(xi+1.0,yi), fx),
    mix(hash2(xi,yi+1.0), hash2(xi+1.0,yi+1.0), fx),
    fy
  );
}

float fbm2(float x, float y) {
  float v = 0.0, a = 0.55, freq = 1.0;
  for (int i = 0; i < 5; i++) {
    v += a * vnoise2(x*freq, y*freq);
    a *= 0.48; freq *= 2.07;
  }
  return v;
}

Jeder Terrain-Punkt berechnet seine eigene Höhe im Vertex-Shader — keinerlei CPU-Beteiligung für 32.400 Punkte pro Frame. Die uTime-Uniform lässt den Noise-Input langsam driften und gibt dem Terrain seine Anziehungskraft:

void main() {
  float h = (fbm2(position.x*0.11 + uOffset + uTime*0.06,
                   position.z*0.11) - 0.5) * 5.5;
  vec3 pos = vec3(position.x, h, position.z);
  // ...
}

Jetzt wird’s spannend: Die gleiche Noise-Funktion ist identisch in JavaScript und GLSL implementiert. Warum? Weil die Terrain-Höhen auf der GPU berechnet werden, die Beacon-Positionen aber auf der CPU (aus den Kundendaten). Wenn die Noise-Funktionen auch nur minimal abweichen, schweben Beacons über der Oberfläche oder versinken darin. Also haben wir die Hash-Funktion, die Smoothstep-Interpolation, die FBM-Schleife — alles — Bit für Bit über beide Sprachen hinweg portiert.

Könnte man die JS-Noise-Funktion komplett eliminieren und alles in GLSL machen? Theoretisch ja — mit Transform Feedback oder Pixel-Readback könnte man Beacon-Höhen auf der GPU berechnen. Aber das würde asynchrone Komplexität und GPU-Pipeline-Stalls für ein Problem einführen, das ein paar Zeilen gespiegeltes JavaScript sauber löst. Manchmal ist die spaßige Lösung auch die pragmatische.

Das Highlight

Bewege den Cursor über das Terrain und du siehst, wie es reagiert — Punkte werden heller und größer, wenn die Maus darüberfährt. Auch das ist pure GPU. Die CPU sendet pro Frame eine einzige Welt-Raum-Position (via Raycasting auf die Grundebene), und der Vertex-Shader erledigt den Rest:

if (uHighlightActive > 0.5) {
  vec3 diff = pos - uHighlightPos;
  float d2 = dot(diff, diff);
  if (d2 < r2) {
    float t = 1.0 - sqrt(d2) / 4.5;
    float ease = t * t;
    col = mix(col, vec3(1.0), ease * 0.5);   // brighten toward white
    sz = size * (1.0 + 1.5 * ease);           // enlarge at center
  }
}

Keine zusätzlichen Draw Calls, kein Post-Processing — nur ein Distanz-Check pro Vertex. Das quadratische Easing sorgt für einen weichen, organischen Abfall statt eines harten Kreises.

Performance (oder: Keine Heizung ausliefern)

Eine vollformatige WebGL-Szene auf einer Landingpage ist eine mutige Entscheidung. Sie muss ihren Platz verdienen, indem sie weder den Akku der Nutzer:innen zerstört noch den Lüfter aufheulen lässt. Wir haben echte Zeit darin investiert — und ehrlich gesagt war die kreative Arbeit mit den Einschränkungen einer der befriedigendsten Teile des Projekts.

Das Staunen

Es gibt diesen Moment in Projekten wie diesem — irgendwo zwischen dem dritten Refactor und dem ersten Mal, wo es tatsächlich funktioniert — wo man vergisst, dass man entwickelt, und einfach nur zuschaut. Eine Zeiterfassungs-API verbunden mit einer Noise-Funktion verbunden mit einem Vertex-Shader, und plötzlich leuchten echte Daten auf dem Bildschirm, ziehen an, steigen auf, leben. Dieser Moment ist der Grund, warum wir das machen.

Jede Schicht der Pipeline präsentierte ein kleines Puzzle — wie man Toggls Ein-Jahres-Abfragelimit handhabt, wie man Zeit logarithmisch abbildet damit kleine Kunden nicht unsichtbar werden, wie man JS- und GLSL-Noise synchron hält — und jede Lösung fühlte sich wie eine kleine Entdeckung an statt wie eine Pflichtübung.

Das Beste? Die Visualisierung aktualisiert sich selbst. Pipeline-Skript ausführen, und neue Kundenbeziehungen erscheinen als neue Beacons. Das Terrain wächst mit dem Unternehmen. Es ist keine statische Illustration — es ist ein lebendiges Protokoll, das uns immer wieder überrascht.