Teknik Notlar

MindLand'i inşa ederken karşılaştığım bazı teknik zorluklar

Giriş

Merhaba! Bu yazımda size oluşturduğum şehir temalı zihin haritası projesinden ve bazı teknik detaylarından bahsedeceğim.

Üniversiteye ilk başladığımdan beri yazılım konularına çok ilgim vardı. Youtube'den dersler izleyerek HTML, CSS ve JavaScript öğrenmiştim. Tatil zamanlarında da küçük projeler yaparak öğrendiklerimi unutmamaya çalışıyordum. Böyle bir fikir de ortaya çıkınca bu proje üzerinde çalışmaya başladım.

Aşağıda proje üstünde çalışırken karşıma çıkan bazı önemli sorunlar ve onların çözümlerinden bahsettim: mimari ve tech stack seçimleri, canvas üzerinde metin işleme, geri al/yinele ve seçim sistemleri, tarayıcılar arası performans farkları. Uzman bir yazılımcı olmadığım için "Şöyle yapsaydın daha iyi olurdu, şu yöntem daha uygun olurdu…" diyeceğiniz noktalar olabilir. Zaten benim de öğrenme sürecim devam ediyor, bu alandaki bilgimin çoğunu projeyi yapma sürecinde edindim. Bundan dolayı uygulamayı denemeniz ve gördüğünüz hataları/eksikleri söylemeniz benim için en değerli geri bildirim olur.

Geliştirme Sürecinde Karşılaştığım Teknik Zorluklar ve Çözümlerim

1. Kısım: Mimarinin Temeli ve Tech Stack Seçimi

Mimaride Hangi Tech Stack'i Kullanacağıma Karar Verme Sürecim

  • React: virtual DOM canvas'a diff uygulayamıyor, canvas tek bir bitmap, içindeki şekiller DOM düğümü değil. Her state değişiminde/etkileşimde yüzlerce şekli baştan çizmek zorunda kalırdım, deklaratif yapının kazancı sıfır olurdu.
  • react-konva: aynı sorunu iki soyutlama katmanıyla çözmeye çalışıyor (React state + imperatif sahne grafiği senkronizasyonu). Benim ölçeğimde bu ekstra katman gereksiz karmaşıklık olurdu, hata ayıklamayı zorlaştırabilirdi.
  • PixiJS: WebGL tabanlı, gerçekten güçlü, ama bu proje için overkill. Ekranda aynı anda birkaç yüz vektörel şekil var (bina, tabela, yol); GPU hızlandırmasına ihtiyaç duyacak bir render yükü yok.
  • Konva.js: Canvas 2D tabanlı, hazır sahne grafiği (scene graph). Konva.Rect, Konva.Path, Konva.Group ile hiyerarşik tanım. Hit detection ve drag&drop kutudan çıkıyor. Layer başına tek bir <canvas> kullandığı için DOM elemanı sayısı yüzlerce node olsa bile layer sayısıyla sınırlı kalıyor. Sonuçta deklaratif değil imperatif bir çizim motoruna ihtiyacım vardı, Konva bu ihtiyacıma tam olarak cevap veriyordu.

UI Katmanı Kararım: Framework Yok, Reaktif Fonksiyonlar

UI için de aynı ilke (minimum bağımlılık, düşük bakım yükü) geçerli oldu, ayrı bir framework sokmadım. Bunun yerine state değişimlerine göre arayüzü yeniden çizen küçük, saf fonksiyonlar yazdım:

  • State → UI: seçim durumuna göre (tekli, çoklu, boş alan) inspector ve context-menu fonksiyonları çalışıyor; topbar ise geçmiş (undo/redo) durumuna göre kendini güncelliyor. Her biri kendi HTML'ini oluşturup ilgili CSS sınıflarını ekleyip çıkarıyor.
  • UI → Canvas: UI ve çizim katmanı doğrudan konuşmuyor; arada basit bir EventBus var. Birinde olan değişiklik diğerini tetikliyor ama hiçbiri diğerinin implementasyonunu bilmiyor.
  • Sıfır bağımlılık: tek bir UI kütüphanesi eklemedim, her şey saf JavaScript ve tarayıcı API'leri.

Bu yapının kazancı şu oldu: toplam bundle'da UI framework'ünün kapladığı onlarca KB'lık yer yok, hata ayıklaması kolay (hangi state'in hangi render'ı tetiklediği net). İhtiyaç büyürse ileride bir kütüphane eklemek için altyapı hazır, ama bugün gerek yok.

2. Kısım: Canvas Üzerinde Metin İşlemenin Zorlukları

Bu, projedeki en zorlu problemdi:

  • Canvas metin düzenleme bilmez. Konva.Text bir çizim yüzeyidir, input alanı değildir. Kullanıcı bir tabelaya tıkladığında canvas üzerine bir DOM textarea açıp onu düğümün ekrandaki haline piksel hassasiyetinde oturtmak gerekiyordu (zoom × düğüm ölçeği, font, padding, hizalama).
  • İki dünya senkronizasyonu için şu noktalar önemliydi: textarea kendi kurallarıyla akıyor (wrap, scrollHeight), canvas tarafında her içerik tipinin (tabela, pano, pankartlı bina, mağaza, bahçe) kendi panel geometrisi var, bu yüzden her içerik tipi için ayrı karakter/satır/yükseklik sınırları ve resize fonksiyonları tanımladım.
  • Kullanıcı yazı yazarken her tuş vuruşunda DOM ve canvas'ın anlaşması gerekiyordu. Çözüm, her input'ta metni Konva düğümüne aktarıp doğal yüksekliğini ölçmek (height(null) → otomatik yükseklik). Panel bu yüksekliğe göre uzuyor, textarea yüksekliğini scrollHeight'ten alıyor. Yani akışı DOM yönetiyor, geometriyi ise canvas yönetiyor.
  • Sınır kontrolü tek noktada olması için üç kapı (satır/karakter sınırını aşmadan engelleyen keydown, aşımı geri alan input, ölçüp gerekirse geri saran stil uygulaması) tek bir veri tablosundan besleniyor; aşımda rollback, yapıştırmada akıllı kısaltma, stil değişikliğinde (font değişimi, yazı kalınlaştırma gibi) ölçüm yapılıp, aşım varsa geri alınıyor.
  • İçerik tipi değişiminde, örneğin pankartlı binadan tabelaya veya mağazadan panoya geçişte aynı metin yeni sınırlara kırpılıyor (önce satır, sonra yükseklik). Bu kırpma undo'ya komut olarak kaydediliyor.

En zor kısım tek başına bunların hiçbiri değildi, hepsini tutarlı tutmaktı. Aynı doğal yükseklik ölçümü panel resize'ını, sınır kontrollerini ve kırpmayı beslediği için "yazarken görülen şey" ile "yazdıktan sonra kaydedilen şey" arasındaki sapmalar gideriliyor.

3. Kısım: Command Pattern, Ağaç Taraması ve Tekli/Çoklu Seçim İşlemleri

Command Pattern ve Nesnelerin Silinmesi Sonrasında Silinmenin Geri Alınması

Şehir üzerinde her işlem geri alınabilir olmalıydı. Bunun için command pattern kurdum.

  • Şehri değiştiren her işlem bir Command nesnesi. Komutlar execute() ve undo() ile yürütülüyor.
  • Hub değiştiren komutlar canlı referansları değil, ID + serialize edilebilir durumları tutuyor. Silinen bir alt ağaç undo'da "cesedi canlandırma" şeklinde değil, serialize edilmiş veriden ID'leri ve ebeveyn-çocuk bağlarını koruyarak sıfırdan yeniden inşa edilmek suretiyle geri geliyor; yeni nesneler aynı ID'lerle registry'ye yeniden kaydoluyor.
  • Butonla başlatılan işlemler historyManager.do(cmd) ile çalışıyor; sürükleme veya metin düzenleme gibi zaten tamamlanmış imperatif işlemler historyManager.registerDone(cmd) ile yeniden execute edilmeden doğrudan historyManager'a işleniyor.
  • Yan etkiler komuta dahil. Örneğin bir çocuğu silmek, kardeşlerin yeniden dizilmesini de tetikliyor (rearrangeChildren). Bu yan etki komuta snapshot olarak kaydediliyor, undo silmeyi geri alırken şehrin düzeni de korunuyor.

Ağaç Taraması Yerine Map: O(1) Erişim

  • Şehir bir ağaç: her hub, ebeveynini ve çocuklarını nesne referanslarıyla biliyor. Bu yapı geometri işlemleri için gerekli (moveSubtree, rearrangeChildren).
  • Ama "bu ID hangi düğüm?" ayrı bir soru. undo/redo, seçim görselleri, yükleme (deserileştirme) ve yol güncellemeleri bunu sürekli soruyor. Her seferinde ağacı özyinelemeli taramak O(n) olurdu; bunun yerine HubRegistry ve DecorationRegistry adında iki Map kurdum. Oluşturma fonksiyonları kayıt açar, destroy fonksiyonları siler, get(id) O(1) döner.
  • Kural basit: geometri için ağaç, kimlik için Map. İkisi birbirinin alternatifi değil, tamamlayıcısı.
  • Map'te sızan (unregister edilmemiş) bir kayıt hem bellek sızıntısı hem de undo'nun sahneye geri getirebileceği bir hayalet düğüm demek. Bu yüzden kayıt/silme işlemleri çağıran tarafın insafına bırakılmadı. Doğrudan fabrika ve destroy fonksiyonlarının içine gömüldü.

Tekli ve Çoklu Seçim, Marquee Sistemi

Tekli ve çoklu seçim ve işlemlerini yönetmek için SelectionManager'ı kurdum.

  • Tek kaynak SelectionManager. "type:id" anahtarlı bir Map; select/toggle/setMany/clear hepsi EventBus'ta selection:changed olayı yayıyor, halo görselleri bu olayı dinleyip kendini tazeliyor. UI ile canvas burada da birbirini tanımıyor.
  • Marquee performansı için boş alanda sürükleme ilk hareket ettiğinde Konva'nın hit graph'ına her karede sormak yerine bir kez snapshot alıyorum (hub içerik rect'leri + dekorasyon rect'leri) ve kesişimleri bu snapshot üzerinde hesaplıyorum.
  • Çoklu seçim sonrası seçili ögelerin sürüklenmesinde yalnızca "en üstteki" seçili hub'lar alt ağacı taşıyor (getTopmostSelectedHubs). Aksi halde seçili bir çocuk, seçili ebeveyninin içinde iki kez taşınmış olurdu.
  • Tüm harita, hub ağaçları root hub'dan köken aldığı için root hub asla silinemiyor; kullanıcı silmeye çalıştığında uyarı gösteriliyor.
  • Seçili atası olan düğümler silme listesinden atlanıyor (çift silmeyi önlemek için), rearrangeChildren da etkilenen ebeveyn başına yalnızca bir kez çalışıyor.

4. Kısım: Firefox'ta Yaşadığım Performans Sorunu ve Ölçümlerim

Uygulamayı farklı tarayıcılarda test ederken şöyle ilginç bir durumla karşılaştım: Aynı harita Chromium tabanlı tarayıcılarda akıcı çalışırken Firefox'ta kasıyor, zooming/panning sırasında belirgin takılmalar oluyordu. FPS ölçümü yaptığımda Chrome'da 60 fps, Firefox'ta 21 fps sonucunu gördüm.

  • Katman başına senkron çizim sürelerini ölçtüm. Firefox'ta yol katmanı 53 düğümle 32 ms sürüyordu, Chrome aynı çizimi 0.9 ms'de yapıyordu.
  • Yol katmanındaki sonuçtan sonra her yolun bükülmesini sağlayan sarı kontrol noktalarının gölge bulanıklıkları üzerine yoğunlaştım. Bu noktalar görünmezken (opacity: 0) bile tanımlı gölge bulanıklığı (shadowBlur) yüzünden her yeniden çizimde bir gölge geçişi çalışıyordu. Çözüm olarak gölgeyi görünürlüğe bağladım. Varsayılan olarak kapalı (shadowEnabled(false)), yalnızca hover/sürükleme sırasında açık.
  • Bir diğer mesele şuydu: Pan/zoom sırasında her fare hareketinde Konva "imlecin altında ne var?" diye sahneye soruyordu. Firefox'ta bu sorgu senkron çizimi zorluyordu. Çözüm olarak pan/zoom boyunca katmanlarda hit-test'i duraklattım (listening: false); zoom'da son tekerlek hareketinden 150 ms sonra, pan'da ise sürükleme bittiği anda tekrar açtım. Pan sırasında hover geri bildirimi zaten gereksizdi.
  • Bu 2 düzeltmeden sonra Firefox 21 fps'ten 60 fps civarına çıktı. İki düzeltme de görsel davranışı hiç değiştirmedi, yalnızca gereksiz işleri kapattı.

Kapanış

Uygulama hakkındaki her türlü görüş, öneri, eleştirinizi (kullanım kolaylığı, performans ve akıcılık, bug'lar, UI tasarımı…) bekliyorum. Şimdiden teşekkürler!