Klad: Büyük ağaç yapıları için canvas tabanlı bir tree engine
Klad, web için bir tree engine. Ağacı bir Web Worker içindeki canvas üzerinde yerleştirip çiziyor. Framework component'lerini ise yalnızca ekranda görünen ve okunacak kadar yakınlaştırılmış node'lar için mount ediyor.
Yazma sebebim şu: DOM tabanlı org chart kütüphaneleri büyük ağaçlarda yavaşlıyor. Her node bir element, her bağlantı çizgisi ayrı bir element. Beş yüz kişilik bir şemada tarayıcının layout'unu hesaplayıp stillendireceği birkaç bin element oluyor ve bunların çoğu herhangi bir anda ekran dışında kalıyor.
Listelerde bu iş virtualization ile çözülüyor; liste tek boyutlu olduğu için işe yarıyor. Ağaç ise iki boyutlu bir düzleme yerleşiyor ve node'lar arasında bağlantılar var, dolayısıyla aynı yöntem doğrudan uygulanamıyor. Kütüphanelerin çoğu da denemiyor.
Temel kullanım
data düz bir dizi ve varsayılan değeri olmayan tek seçenek.
import { createKlad } from '@klad/core'
const chart = createKlad(document.getElementById('chart')!, {
data: [
{ id: 'ceo', name: 'Jamie Fox', title: 'CEO' },
{ id: 'cto', parentId: 'ceo', name: 'Amy Chen', title: 'CTO' },
{ id: 'cfo', parentId: 'ceo', name: 'Priya Rao', title: 'CFO' },
],
})
chart.on('nodeClick', ({ id, item }) => console.log('clicked', id, item))
Her kayıt { id, parentId?, ...kendi alanlarınız } şeklinde. İç içe bir children yapısı kurmanız gerekmiyor. parentId hiçbir kayda denk gelmiyorsa o node exception yerine warning event'i ile root kabul ediliyor. Veri çoğunlukla referential integrity garantisi vermeyen bir veritabanından geldiği için bunu bilerek böyle yaptım.
nodeSize
Layout Web Worker içinde çalışıyor ve worker'da DOM yok. Mount edilecek bir element ya da çağrılabilecek bir getBoundingClientRect() yok, bu yüzden node boyutlarını siz bildiriyorsunuz:
nodeSize: Size | ((item: NodeData) => Size) // Size = { w: number; h: number }
Kart yüksekliği sonradan değişirse api.refresh() bütün boyutları yeniden okuyup layout'u kuruyor; açık/kapalı durumu, kamera konumu ve highlight korunuyor.
Worker başlatılamazsa
Sebebini console.warn ile yazıp main thread'e düşüyor. Üç durumda oluyor: worker script'lerini engelleyen bir CSP, OffscreenCanvas desteklemeyen bir tarayıcı ya da 2D context'i başkası tarafından alınmış bir canvas. Seçenekler, event'ler ve API aynı kalıyor. worker: false ile bu davranışı kendiniz de zorlayabilirsiniz.
Component'ler nerede devreye giriyor
Sadece canvas kullanmak design system'inizden vazgeçmek olurdu. Bu yüzden canvas tek başına değil; bir level-of-detail sisteminin en ucuz katmanı.
export type LodTier = 'block' | 'label' | 'full'
export const DEFAULT_LOD: LodThresholds = { text: 0.25, overlay: 0.6 }
block— 0.25 zoom altında sadece kutular ve bağlantılar çiziliyor. O ölçekte yazı zaten okunmuyor.label— node başına kırpılmış tek satır metin.full— 0.6 ve üzerinde kartın tamamı çiziliyor, DOM overlay devreye giriyor.
Bir node gerçek bir element'e ancak son katmanda dönüşüyor. Vue slot'unuz, React render prop'unuz ya da kendi DOM'unuz burada mount ediliyor; siz gezindikçe bu element'ler havuzlanıp yeniden kullanılıyor.
flowchart LR
Data["{ id, parentId }"] --> Worker[Web Worker: layout]
Worker --> Canvas[Canvas cizimi]
Canvas --> Zoom{"zoom >= 0.6"}
Zoom -->|hayir| Done[Sadece canvas]
Zoom -->|evet| Overlay[Gorunen node'lara component]
Küçük bir not: zoom değeri NaN gibi geçersiz bir şey olursa bütün >= karşılaştırmaları başarısız oluyor ve sistem en ucuz katman olan block'a düşüyor. Hata durumunda olması gereken davranış bu.
Aynı veri, dört farklı şekil
Org chart ile dosya gezgini arasındaki fark veride değil, çizim şeklinde. Bu yüzden layout bir seçenek; aynı düz dizi dördünü de besliyor.
createKlad(host, { data, layout: 'sunburst' })
| Layout | Ne için | Nasıl büyür |
|---|---|---|
tidy |
Org chart, karar ağaçları, yukarıdan aşağı okunan yapılar. Varsayılan. | Genişleyerek, hızlı |
file |
Dosya gezginleri, outline'lar, iç içe uzun listeler. | Sadece aşağı |
radial |
Geniş ve sığ ağaçlar: çok dallı ama her dalı kısa bir kök. | Dışa doğru |
sunburst |
Oranlar: bir dalın kardeşlerine göre ne kadar yer tuttuğu. | Dışa doğru, sınırlı |
orientation sadece tidy için geçerli: 'tb' | 'bt' | 'lr' | 'rl'. Kardeş sırasını ters çevirmek için rtl var.
file ölçek açısından diğerlerinden ayrılıyor: ağaç büyüdükçe genişliği artmayan tek layout bu. Bin kardeş node bin satır demek, bin sütun değil. Büyük ve çoğunlukla kapalı duran yapılarda kullanılabilir kalan tek şekil.
Tamamı baştan yüklenemeyen ağaçlar
Chart'ların çoğu çizime başlamadan önce ağacın tamamına ihtiyaç duyuyor. Bu da şeklin en çok işe yaradığı senaryoları eliyor: listelenemeyecek kadar büyük bir dosya sistemi, API arkasındaki bir taksonomi, yüz bin kişilik bir organizasyon.
const chart = createKlad(host, {
data: roots,
mayHaveChildren: (item) => Number(item.childCount) > 0,
loadChildren: (item) => fetch(`/api/children/${item.id}`).then((r) => r.json()),
})
Neden tek seçenek değil de iki tane? Chart yalnızca kendisine verilen veriyi biliyor. data içinde çocuğu olmayan bir node ile gerçek yaprak node birbirinden ayırt edilemiyor; ikisinde de tıklanacak bir işaret yok. Veri çekilmeden önce birinin "burada devamı var" demesi gerekiyor.
Bu bilgiyi mayHaveChildren veriyor. Genelde bir sayaç alanına bakarak cevap veriyorsunuz ve o sayaç yanlış olabilir. loadChildren sonucunda çocuğu çıkmayan node yaprağa dönüşüyor, başka bir şey bozulmuyor.
Çok geniş seviyeler
Dört yüz kişiye bakan bir yönetici ya da on bin dosyalık bir klasör. O seviye okunmaz hale geliyor ve zoom bunu çözmüyor.
const chart = createKlad(host, {
data,
maxChildren: 8,
pinChildren: (item) => watching.has(String(item.id)),
})
Burada da iki seçenek var. maxChildren tek başına bir kırpmadan ibaret; hangi node'lar önce geliyorsa onları gösteriyor. Her biri yüz kişilik beş seviyede gezinirken, işinize yarayan sekiz node'un ilk sekiz sırada olma ihtimali düşük.
Hangilerinin görüneceğini pinChildren belirliyor: üzerinde çalıştığınız kayıtlar, bir arama sonucu, mevcut seçim. Pin'lenen node'lar limite dahil değil, limitten önce geliyor; sekiz limitiyle on node pin'lerseniz onu da görürsünüz. Sıralama her durumda verideki sırayı koruyor, yani pin'lenen node en başa alınmıyor, kardeşleri arasındaki yerinde kalıyor.
Erişilebilirlik
Canvas ekran okuyucular için görünmez. Focus alamaz, rol taşımaz, okunabilir bir içeriği yoktur. Chart'ı canvas'a çizip orada bırakmak, ekran okuyucu kullanan birinin hiç kullanamayacağı bir arayüz demek.
Bu yüzden canvas'ın yanında gizli ama gerçek bir DOM ağacı duruyor. Her node için role="treeitem" taşıyan bir satır var; aria-expanded ve aria-level değerleri güncel tutuluyor. İki nokta önemli:
- Satırlar
display: noneile değil clipping ile gizleniyor.display: noneonları accessibility tree'den de çıkarırdı, o zaman bu ağacı tutmanın anlamı kalmazdı. content-visibility: autokullanılıyor; böylece büyük ağaçlarda bu gizli DOM maliyetli hale gelmiyor.
| Tuş | İşlev |
|---|---|
↑ / ↓ |
Önceki / sonraki satır |
→ |
Kapalı node'u açar, açıksa ilk çocuğuna geçer |
← |
Açık node'u kapatır, kapalı ya da yapraksa parent'a çıkar |
Enter / Space |
Odaktaki satırı açar veya kapatır |
Home / End |
İlk / son satır |
m |
Odaktaki node'u alır, ikinci m odak neredeyse oraya bırakır |
Focus değiştikçe kamera da o node'a kayıyor, yani canvas ekran okuyucuyu takip ediyor.
Düzenleme
dragAndDrop: true verdiğinizde sürükleyerek bir node'u başka bir parent'a ya da iki kardeş arasına taşıyabiliyorsunuz. Node bir seçimin parçasıysa seçimin tamamı birlikte gidiyor. İmleç kapalı bir dalın üzerinde beklerse dal açılıyor, döngü oluşturacak taşımalar reddediliyor, Escape node'u eski yerine koyuyor. Aynı işlemi klavyeden m ile de yapabiliyorsunuz.
Her taşıma gerçekleşmeden önce event olarak bildiriliyor; izin vermek istemediğiniz bir taşımayı preventDefault() ile engelliyorsunuz. Kendi iş kurallarınızı chart'a anlatmanız gerekmiyor.
Mevcut durum
Klad şu an 1.8.0 sürümünde, sadece ESM olarak yayınlanıyor.
| Paket | Ne için |
|---|---|
@klad/core |
Framework bağımsız API. Tek fonksiyon: createKlad. |
@klad/vue |
Vue 3: #node scoped slot'lu <Klad> component'i ve useKlad(). |
@klad/react |
React: render prop'lu <Klad> ve bir ref handle. |
@klad/engine |
Layout, viewport hesapları, spatial index, renderer, worker protokolü. DOM içermiyor. Sadece yeni bir binding yazacaksanız gerekli. |
npm install @klad/core # framework bağımsız
npm install @klad/vue # Vue 3 (>=3.5 <4)
npm install @klad/react # React (>=18)
Paketler birbirinin üzerine kurulu, birini kurmanız yeterli. Vue adapter'ı için ayrıca @klad/core kurmanıza gerek yok.
Gereksinimler: Worker, OffscreenCanvas, ResizeObserver ve Canvas2D. Güncel tarayıcıların hepsinde var.
Engine, core, vue ve react paketlerinde toplam 442 test var; önemli bir kısmı simüle DOM yerine gerçek tarayıcıda koşuyor. Playground'da dört orientation, RTL, değişken node boyutları, dokuz farklı kart tasarımı ve 20.000 node'luk bir stres testi bulunuyor.
İki lisans seçeneği var: varsayılan olarak AGPL v3 veya üzeri, bir de kapalı kaynak bir üründe veya hosted bir serviste AGPL'in kaynak paylaşma yükümlülüğü olmadan kullanmak isteyenler için ticari lisans.
- Dokümantasyon, API referansı ve ayarları canlı deneyebileceğiniz playground: klad.ozdemir.be
- Kaynak kod: github.com/n1crack/klad
Deneyip takıldığınız bir yer olursa bana yazabilirsiniz.