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!
Introduction
Hello! In this post, I'd like to talk about the city-themed mind map project I created and some of
its technical details.
I've been interested in software development since I first started university. I learned HTML, CSS,
and JavaScript by watching tutorials on YouTube. During holidays, I tried to retain what I had
learned by working on small projects. When this idea emerged, I started working on the project.
Below, I discuss some of the important issues I encountered while working on the project and how I
solved them: architectural and tech stack choices, text handling on the canvas, undo/redo and
selection systems, and performance differences across browsers. Since I'm not an expert developer,
there may be points where you'd say, "It would have been better if you had done it this way, or that
method would have been more suitable…" My learning process is still ongoing, and I gained most of my
knowledge in this area while building the project. For that reason, trying out the application and
sharing any bugs or shortcomings you notice would be the most valuable feedback for me.
Technical Challenges and Solutions Encountered During Development
1. Foundation of the Architecture and Tech Stack Selection
The Process of Deciding on the Tech Stack for the Architecture
- React: The virtual DOM cannot apply diffs to a canvas. A canvas is a single
bitmap; the shapes inside it are not DOM nodes. On every state change or interaction I would
have had to redraw hundreds of shapes from scratch, so the benefits of a declarative approach
would have been zero.
- react-konva: It tries to solve the same problem with two layers of abstraction
(React state + synchronization of an imperative scene graph). At my scale this extra layer would
have been unnecessary complexity and could have made debugging harder.
- PixiJS: WebGL-based and genuinely powerful, but overkill for this project.
There are only a few hundred vector shapes on screen at once (buildings, signs, roads); there is
no render load that requires GPU acceleration.
- Konva.js: Canvas 2D-based with a ready-made scene graph. Hierarchical
definition is possible with Konva.Rect, Konva.Path, and Konva.Group. Hit detection and
drag-and-drop come out of the box. Because each layer uses a single <canvas>, the number
of DOM elements stays limited to the number of layers even when there are hundreds of nodes. In
the end I needed an imperative drawing engine rather than a declarative one, and Konva matched
that need precisely.
UI Layer Decision: No Framework, Just Reactive Functions
The same principle (minimum dependencies, low maintenance overhead) applied to the UI as well, so I
did not introduce a separate framework. Instead, I wrote small, pure functions that redraw the
interface according to state changes:
- State → UI: Inspector and context-menu functions run according to the selection
state (single, multi, or empty area); the topbar updates according to the history (undo/redo)
state. Each one generates its own HTML and adds or removes the relevant CSS classes.
- UI → Canvas: The UI and the drawing layer do not communicate directly to
trigger updates; a simple EventBus sits between them. A change in one triggers the other, but
neither knows the other's implementation.
- Zero dependencies: I did not add a single UI library; everything is pure
JavaScript and browser APIs.
The benefit of this structure is that the total bundle does not carry the tens of kilobytes a UI
framework would have occupied, and debugging is straightforward (it is clear which state triggers
which render). The foundation is ready to add a library later if needs grow, but there is no need
for one today.
2. Challenges of Text Handling on the Canvas
This was the hardest problem in the project:
- Canvas has no concept of text editing. Konva.Text is a drawing surface, not an input field. When
the user clicked a sign, a DOM textarea had to be opened over the canvas and positioned with
pixel precision to match the node's on-screen appearance (zoom × node scale, font, padding,
alignment).
- For synchronizing the two worlds the following considerations were key: the textarea flows
according to its own rules (wrap, scrollHeight); each content type (sign, billboard, banner
building, store, garden) has its own panel geometry, so I defined separate character/line/height
limits and resize functions for every content type.
- While the user was typing, the DOM and the canvas had to stay in agreement on every keystroke.
The solution was to transfer the text to the Konva node on each input event and measure its
natural height (height(null) → automatic height). The panel extends according to this height;
the textarea takes its height from scrollHeight. In other words, flow is managed by the DOM,
geometry by the canvas.
- To keep boundary control in a single place, three gates (a keydown that blocks before a line or
character limit is exceeded, an input that rolls back any overflow, and a style application that
measures and rolls back if necessary) are all fed from one data table. On overflow a rollback
occurs; on paste a smart truncation is applied; on style changes (font change, bold, etc.) a
measurement is taken and the change is undone if it causes an overflow.
- When the content type changes—for example from a banner building to a sign, or from a store to a
billboard—the same text is clipped to the new limits (first by lines, then by height). This
clipping is recorded as an undo command.
The hardest part was not any of these individual pieces, but keeping them all consistent. Because the
same natural-height measurement feeds the panel resize, the boundary checks and the clipping,
discrepancies between "what is seen while typing" and "what is saved after typing" are eliminated.
3. Command Pattern, Tree Traversal, and Single/Multi Selection Operations
Command Pattern and Restoring Objects After Deletion
Every operation on the city had to be undoable. For this reason I implemented the command pattern.
- Every operation that changes the city is a Command object. Commands are executed via execute()
and undo().
- Commands that modify hubs store IDs and serializable states rather than live references. A
deleted sub-tree is not restored on undo by "reviving the corpse"; instead it is rebuilt from
scratch from the serialized data while preserving IDs and parent–child links. The new objects
are re-registered in the registry under the same IDs.
- Operations started by a button run through historyManager.do(cmd). Already-completed imperative
operations such as dragging or text editing are recorded directly with
historyManager.registerDone(cmd) without being re-executed.
- Side-effects are included in the command. Deleting a child, for example, also triggers a
rearrangement of its siblings (rearrangeChildren). This side-effect is stored in the command as
a snapshot, so that when the deletion is undone the layout of the city is preserved as well.
Map Instead of Tree Traversal: O(1) Access
- The city is a tree: every hub knows its parent and children through object references. This
structure is required for geometric operations (moveSubtree, rearrangeChildren).
- But "which node has this ID?" is a separate question. Undo/redo, selection visuals, loading
(deserialization) and path updates ask it constantly. Traversing the tree recursively each time
would be O(n); instead I built two Maps named HubRegistry and DecorationRegistry. Creation
functions register the object, destroy functions remove it, and get(id) returns in O(1).
- The rule is simple: the tree for geometry, the Map for identity. They are not alternatives to
each other but complementary.
- A leaked (unregistered) entry in the Map means both a memory leak and a ghost node that undo
could bring back onto the scene. For this reason registration and removal were not left to the
mercy of the caller. They were embedded directly inside the factory and destroy functions.
Single and Multi Selection, Marquee System
I built SelectionManager to handle single and multi selection and related operations.
- Single source of truth is SelectionManager: a Map keyed by "type:id". select / toggle / setMany
/ clear all publish a selection:changed event on the EventBus; halo visuals listen to this event
and refresh themselves. UI and canvas remain unaware of each other here as well.
- For marquee performance, when dragging starts on empty space I take a single snapshot of Konva's
hit graph (hub content rects + decoration rects) instead of querying it every frame, and I
calculate intersections against this snapshot.
- When dragging selected items after multi-selection, only the "topmost" selected hubs move their
sub-trees (getTopmostSelectedHubs). Otherwise a selected child would be moved twice: once
directly as a selected item, and once again as part of its already-selected parent's subtree.
- Because the entire map and all hub trees originate from the root hub, the root hub can never be
deleted; if the user attempts to delete it a warning is shown.
- Nodes that have a selected ancestor are skipped in the deletion list (to avoid double deletion),
and rearrangeChildren runs only once per affected parent.
4. Performance Issue I Encountered in Firefox and My Measurements
While testing the application in different browsers I came across an interesting situation: the same
map ran smoothly in Chromium-based browsers, yet in Firefox it stuttered, with noticeable lag during
zooming and panning. When I measured FPS I obtained 60 fps in Chrome and 21 fps in Firefox.
- I measured the synchronous drawing times per layer. In Firefox the road layer with 53 nodes took
32 ms; Chrome performed the same draw in 0.9 ms.
- After seeing the result on the road layer I focused on the shadow blur of the yellow control
points that bend each road. Even when these points were invisible (opacity: 0) the defined
shadow blur still caused a shadow pass to run on every redraw. As a solution I tied the shadow
to visibility: it is disabled by default (shadowEnabled(false)) and enabled only during hover or
drag.
- Another issue was that on every mouse movement during pan/zoom Konva asked the scene "what is
under the cursor?". In Firefox this query forced a synchronous redraw. The solution was to pause
hit-testing on the layers for the duration of pan/zoom (listening: false); I re-enabled it 150
ms after the last wheel event for zoom, and immediately when the drag ended for pan. Hover
feedback was unnecessary during pan in any case.
- After these two fixes Firefox rose from 21 fps to around 60 fps. Neither change altered the
visual behavior; they merely eliminated unnecessary work.
Closing
I look forward to any comments, suggestions or criticisms you may have about the application (ease of
use, performance and fluidity, bugs, UI design…). Thank you in advance!