Skip to content
Yusuf Özdemir
Klad: Büyük ağaç yapıları için canvas tabanlı bir tree engine
ALL ARTICLES

Klad: Büyük ağaç yapıları için canvas tabanlı bir tree engine

9 MIN READ 1,664 WORDS
ALSO IN English

Klad, web için yazdığım bir tree engine. Ağacın yerleşimini ve çizimini bir Web Worker içindeki canvas'ta yapıyor, framework component'lerini de yalnızca ekranda görünen ve okunacak kadar yakınlaştırılmış node'lar için mount ediyor.

DOM tabanlı org chart kütüphaneleri büyük ağaçlarda yavaşlıyor, çünkü her node bir element ve her bağlantı çizgisi ayrı bir element oluyor. Beş yüz kişilik bir şemada tarayıcının layout'unu hesaplayıp stillendireceği birkaç bin element birikiyor; bunların büyük kısmı da herhangi bir anda ekranın dışında duruyor.

Listelerde çözüm virtualization. Liste tek boyutlu olduğu için ekrandaki satırları çizip gerisini atlamak yeterli oluyor. Ağaçta işler karışıyor: node'lar iki boyutlu bir düzleme yerleşiyor ve aralarında çizilmesi gereken bağlantılar var. Klad'ın yaptığı şey, o iki boyutlu sahneyi canvas'a taşıyıp DOM'u sadece gerçekten okunan yerlerde kullanmak.

Temel kullanım

data düz bir dizi; varsayılanı olmayan tek seçenek de bu.

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, yani iç içe bir children yapısı kurmanıza gerek kalmıyor. parentId hiçbir kayda denk gelmezse o node exception fırlatmak yerine warning event'iyle root kabul ediliyor. Veri çoğu zaman referential integrity garantisi vermeyen bir veritabanından geldiği için bunu bilerek böyle bıraktım.

nodeSize

Layout worker içinde çalışıyor, dolayısıyla ölçüm yapabileceği bir DOM'a erişemiyor. 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 bu sırada korunuyor.

Worker başlatılamazsa

Sebebi console.warn ile yazılıyor ve çizim main thread'e düşüyor. Pratikte bunu tetikleyen şeyler: worker script'lerini engelleyen bir CSP, OffscreenCanvas desteklemeyen bir tarayıcı, 2D context'i başkası tarafından alınmış bir canvas. Seçenekler, event'ler ve API bu durumda da aynı kalıyor. worker: false verirseniz aynı yola kendiniz de girebilirsiniz.

Component'ler nerede devreye giriyor

Her şeyi canvas'a çizmek, kullandığınız design system'i çöpe atmak anlamına gelirdi. Klad'da canvas bir level-of-detail sisteminin en ucuz katmanı olarak duruyor.

export type LodTier = 'block' | 'label' | 'full'

export const DEFAULT_LOD: LodThresholds = { text: 0.25, overlay: 0.6 }
  • block — 0.25 zoom'un 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 ve 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]

Zoom değeri bir şekilde NaN olursa bütün >= karşılaştırmaları false dönüyor ve sistem en ucuz katman olan block'a düşüyor. Bozuk bir durumda görmek istediğim davranış buydu.

Aynı veri, dört farklı şekil

Bir org chart ile bir dosya gezgininin verisi aynı; değişen şey o verinin nasıl çizildiği. Bu yüzden layout bir seçenek ve 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 tarafında diğerlerinden ayrılıyor, çünkü ağaç büyüdükçe genişliği artmayan tek layout bu. Bin kardeş node bin satıra dönüşüyor, genişlik sabit kalıyor. Büyük ve çoğunlukla kapalı duran yapılarda elimde kalan tek kullanışlı şekil oldu.

Tamamı baştan yüklenemeyen ağaçlar

Chart'ların çoğu çizime başlamadan önce ağacın tamamını istiyor. Bu da şeklin en çok işe yaradığı senaryoları eliyor: listelenemeyecek kadar büyük bir dosya sistemi, API arkasındaki bir taksonomi.

const chart = createKlad(host, {
  data: roots,
  mayHaveChildren: (item) => Number(item.childCount) > 0,
  loadChildren: (item) => fetch(`/api/children/${item.id}`).then((r) => r.json()),
})

İki ayrı seçenek olmasının sebebi şu: chart yalnızca kendisine verilen veriyi görüyor. data içinde çocuğu bulunmayan bir node ile gerçek yaprak node ona aynı görünüyor, ikisinde de tıklanacak bir işaret olmuyor. 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ış olabiliyor. loadChildren sonucunda çocuğu çıkmayan node sessizce yaprağa dönüşüyor, geri kalan hiçbir şey etkilenmiyor.

Çok geniş seviyeler

Dört yüz kişiye bakan bir yönetici ya da on bin dosyalık bir klasör, o seviyeyi okunmaz hale getiriyor. Zoom da bunu kurtarmıyor.

const chart = createKlad(host, {
  data,
  maxChildren: 8,
  pinChildren: (item) => watching.has(String(item.id)),
})

Burada da iki seçenek var. maxChildren basit bir kırpma yapıyor, 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 limitten önce geliyor ve limite dahil edilmiyor, yani sekiz limitiyle on node pin'lerseniz onu da görürsünüz. Sıralama her durumda verideki sırayı koruyor; pin'lenen bir node başa alınmadan kardeşleri arasındaki yerinde kalıyor.

Erişilebilirlik

Canvas ekran okuyucular için görünmez bir yüzey. Chart'ı canvas'a çizip orada bırakmak, ekran okuyucu kullanan birinin hiç açamayacağı bir arayüz üretiyor.

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. Satırlar display: none yerine clipping ile gizleniyor, çünkü display: none onları accessibility tree'den de çıkarırdı ve bu ağacı tutmanın anlamı kalmazdı. Gizli DOM'un maliyetini düşük tutmak için de content-visibility: auto kullanılıyor.

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 bir node'u sürükleyip başka bir parent'a ya da iki kardeşin 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 de node'u eski yerine bırakıyor. Aynı işi klavyeden m ile yapabiliyorsunuz.

Her taşıma gerçekleşmeden önce event olarak bildiriliyor, izin vermek istemediğiniz taşımayı preventDefault() ile engelliyorsunuz. Böylece kendi iş kurallarınızı chart'a anlatmak zorunda kalmıyorsunuz.

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.

Deneyip takıldığınız bir yer olursa bana yazabilirsiniz.

More to read