Skip to content

Mobile Friendly Network Mapper

Ed Mozley edited this page Sep 4, 2026 · 2 revisions

Mobile‑Friendly: Network Mapper

The seventeenth module, and the second canvas after Process Mapper. Three pages β€” the diagram list, the mapper and the guide. Shipped in #1464‑#1468, mobile.css v129 / mobile.js v52, LAYER 31.

⚠️ This round shipped in two halves, and the second one overturns the first's scope. #1464‑#1467 made the module readable and said plainly that authoring stayed on the desktop, for a reason worth keeping (below). Ed then asked for the drag, so #1468 added placing, moving and deleting a node with a finger β€” see Ed asked for the drag anyway. The reasoning that declined it is left standing rather than rewritten, because it is what shaped what got built: the objection was never "placing is impossible", it was "placing without a way back is a trap", and the answer was to supply the way back. Drawing connectors is still desktop-only.

Read Mobile‑Friendly first for the strategy and the one hard rule, and Techniques & Tricks for the catalogue this round draws on.


The pre‑flight, in five greps

Run before touching anything, and it settled most of the round:

Grep Result
a pre‑existing @media in the module none β€” but see the guide below, where a shared stylesheet had one
localStorage none
:hover revealing a control (Β§26) one, and it is already correct β€” below
its own modal class πŸ”΄ .nm-modal / .nm-modal-overlay, invisible to LAYER 3 β€” five of them
the nm- prefix in any other module (Β§15) clean β€” no other module or shared stylesheet defines a single .nm-* class

That last one exists because of what pm- did to Process Mapper: two modules independently picked the same three‑letter prefix and one module's entire layout mode ended up arranging the other's panel. It is now a standing check, and this time it came back clean. Everything is still scoped on [data-mobile-module="network-mapper"], which costs nothing and means the next module to reach for nm- cannot inherit this layer.


⭐ Deciding the scope before writing anything

Β§22 splits drag into the kind that survives touch and the kind that does not:

network-mapper.js : 17 Γ— mousedown / mousemove / mouseup
                     0 Γ— touchstart / touchmove / pointerdown

So a node cannot be repositioned with a finger, and the connector drag β€” mousedown on an edge handle, mousemove, mouseup β€” has no touch path either. Between them that is the whole of authoring's geometry.

πŸ”‘ Say what the round is for, out loud, before building it. The round makes a map readable: reach the list, open a diagram, pan and zoom it, tap a node and read its CMDB record, follow the link through, switch versions, present, export.

πŸ”΄ But this module has a half‑working case Process Mapper did not

The class palette is HTML5 drag‑and‑drop, not a hand‑rolled one:

`<div class="nm-palette-tile" draggable="true" …>`   // + dragstart / dragover / drop

which Β§22 says does work on a phone β€” iOS Safari has driven that API from a long press since iOS 11. Dropping a new node is therefore very probably possible here, and onCanvasDrop places it at the drop point and opens the object picker, so it would even land where you meant.

It is still not offered, and the reason is the asymmetry:

⭐ You could place a node and then never move it again. A mis‑drop on a 360px canvas would be permanent. The missing half is not the action, it is the recovery β€” and a control that half works is worse than one that is honestly not there.

That is a different argument from Process Mapper's ("the drag simply does not fire") reaching the same answer, and it is the one worth remembering: Β§22 tells you whether a drag fires, not whether the feature it belongs to is usable. Check what happens after the drag as well.

And the one hover‑gated control was already right

.nm-node:hover  .nm-edge-handle,
.nm-node.selected .nm-edge-handle { opacity: 1; }

.selected is in that selector list, and a tap selects a node β€” so the connector handles are revealed on touch. Not a Β§26 fault, and the first module in the rollout where the pattern arrived written correctly. (They still cannot be dragged; but they are visible rather than silently absent, which is precisely what Β§26 asks for.)


πŸ”΄ Β§30 governs the whole round

All three pages open with body { height: 100vh; overflow: hidden }. So docScrollW === innerWidth cannot fail on any of them, and per Β§30 an assertion that cannot fail is absent, not passing. Every number below is a box, not the document.

What the boxes said, on the real page at 360Γ—740, before anything was written:

diagram.php   .nm-palette          x=0    w=240        67% of the screen
              .nm-canvas           x=240  w=119   πŸ”΄  the diagram
              .nm-editor-actions   w=1206 right=1242   882px off the edge
              .nm-editor-title     w=4    scrollW=87   the diagram's name
help.php      .help-container      h=8977, NO scroller at all
index.php     .nm-toolbar .actions right=464
              .nm-card             w=320 inside a w=304 grid

The layer

31a β€” the two shells

.nm-page and .nm-editor are both height: calc(100vh - 60px) β€” the fifth and sixth container in the rollout built that way (LAYER 21, 27, 29, 30, and these two). Handed back to LAYER 2's 100dvh flex column with flex: 1 1 auto; min-height: 0, the min-height being Β§28 and not optional.

πŸ”΄ 31e β€” the palette, and the 119px canvas

A fixed 240px column taking 67% of the viewport. The third module to hit Β§30's rule of thumb after the Forms approvals sidebar and CMDB browse: no pane holding content may be narrower than roughly 60% of the screen, and 119px is not a layout, it is a symptom.

It becomes a slide‑in sheet with an injected button β€” LAYER 4's chrome, 29d's and 30b's. position: fixed takes it out of the flex row, so the canvas gets its width back with no rule of its own.

⚠️ "drag to canvas" goes with it. The palette header carries that hint, and on a phone the sheet covers the canvas and a drop could never be corrected. An instruction that points at something that is not there measures exactly like one that does, so it is hidden. palette_title β€” "CMDB classes" β€” is direction‑free, stays, and is honestly what the panel now is: the classes in this diagram with their object counts. Zero new locale keys, and the sheet button reuses that same string.

⭐ The canvas was already overflow: auto over a 3000Γ—3000 spacer, so panning is native touch scrolling and needed no JavaScript β€” the same enabler Process Mapper had. Measured: scrollHeight 5000 in a clientHeight of 559, reaching 4441.

⭐ 31d β€” a title measured at 4 pixels, and flex-grow could not fix it

.nm-editor-actions holds fifteen controls and carries flex-shrink: 0, so the bar's other child absorbed the whole difference: .nm-editor-title-area at w=0, and the diagram's own name rendered and completely invisible. The standing lesson that flex-shrink: 0 is the commonest reason a row will not fit a phone, and that it fails by crushing its neighbour rather than by spilling itself β€” with .nm-canvas-wrap clipping, nothing could report it.

The bar stacks: title on its own line, actions beneath as a sideways strip (30c's answer, honest here because zoom, present, both exports and the version picker all work by tap).

And then the title was still 4px. The measurement that mattered:

.nm-back-btn      w=101   flex 0/0/auto   "← All diagrams"
.nm-editor-title  w=4     flex 1/1/auto   "My network"      ← grow AND min-width:0 already set
.nm-version-pill  w=76
.nm-palette-btn   w=131   flex 0/0/auto   "☰ CMDB classes"

101 + 76 + 131 + three 8px gaps = 332 of a 336px line.

πŸ”‘ flex-grow distributes POSITIVE free space. When the other items already fill the line there is none, and a grow factor is inert β€” which looks exactly like a rule that did not apply. min-width: 0 had made the title shrinkable, and that was the whole of its effect: it shrank.

Fixed by wrapping the row and giving the title Β§23's middle basis, flex: 1 1 60% β€” auto puts the pill on a line by itself, 0 lets all four back on to be crushed again. Title now 227px.

31f β€” the node details as a bottom sheet, and a dvh figure that does not travel

LAYER 30f's shape: the 320px right‑hand column moves to the bottom, and the selected node is scrolled into the strip above it. A tap already opens it β€” browsers synthesise the compatibility mouse events after a tap, onNodeMouseDown fires, the node gets .selected, and the module's own translated panel appears. Nothing new built, no string invented.

πŸ”΄ But 58dvh copied straight from LAYER 30f left 71px of map. Process Mapper's bar is 48px; this one is a header (48) plus a stacked editor bar (141) plus a meta row (51) = 240px before the map gets a pixel. The selected node was in the strip β€” the assertion passed β€” and there was no room for anything around it, which is most of why you look at a network diagram.

πŸ”‘ A dvh split only works when the two halves are the whole viewport. Copy one out of a module with a 48px bar into a module with a 240px one and it silently spends the difference on the half you cared about.

Three changes: the sheet to 55dvh; the meta row hidden while the sheet is open (the diagram's provenance is worth nothing while you read one node's record, and it comes straight back); and the canvas capped by margin-bottom: 55dvh rather than max-height, because a margin on a flex: 1 item in a 100dvh column leaves it exactly the rest β€” whatever the bar above has wrapped to, in any locale. A max-height is a second guess at the same number.

Result: 142px of map, up from 71, with the node and its neighbours and their connector labels all visible.

31b/c, g, h, i

  • 31b β€” the list toolbar, whose actions ended at x=464. Stacks; the search takes the width. ⚠️ Β§16: this row sets both justify-content and align-items, and both swap meaning when a row becomes a column. Restated explicitly rather than inherited β€” same for 31d's bar.
  • 31c β€” the card grid, repeat(auto-fill, minmax(320px, 1fr)) measured as a 304px grid holding 320px cards. auto-fill will drop to one column but will not go below the minimum you gave it, and 320 is more than 360 has after gutters. Β§14's family seen from the grid side: a track minimum is a shrink floor by another name.
  • 31g β€” five .nm-modal shells promoted to full‑screen sheets. Fifth module to roll its own modal class (Problem Management, Change Management, Contracts, Process Mapper, this), and as always nothing in the measurements complains β€” a 95%‑wide centred box is perfectly contained. Keyed on the shell, not the five ids.
  • 31h β€” the two dropdowns (#versionsDropdown, #pageDropdown), position: absolute; right: 0; min-width: 320px hung off buttons that 31d had just put in a sideways scroller. Bottom sheets, pure CSS β€” the page toggles them with style.display, which this does not touch, so there is nothing to keep in step.
  • 31i β€” the anti‑zoom sweep, written as an exclusion (input:not([type=checkbox])…) rather than a list of types, so a field added later is covered by default instead of forgotten.

πŸ”΄ Two faults outside the module, both found by opting a guide in

The guide could not be scrolled at all

help.php measured 8977px tall inside a clipping 740px body with no vertical scroller anywhere on it β€” seven of its eight sections unreachable, and every containment check clean. Β§28, and the cause is a third file styling the page β€” the LMS trap:

/* help.css, its own @media (max-width: 900px) */
.help-container { height: auto; }        /* hand the scroll to the document… */
.help-main      { overflow-y: visible; }
/* inbox.css */
body { overflow: hidden; }               /* …which cannot take it */

The fix was three lines of opt‑in: LAYER 16h already gives .help-container the scroller role. Every other module's guide was fine because every other module's guide had opted in.

πŸ”΄πŸ”΄ And the contents strip has been inert on a phone in every guide since LAYER 16

Each guide's own script does helpMain.scrollTo(…) for the jump and binds its scroll‑spy to helpMain.addEventListener('scroll', …). Correct on a desktop, where .help-main is the scroller. Below 900px it is overflow-y: visible and the scroller is .help-container β€” and scrollTo on an element that does not scroll throws nothing and does nothing.

Measured identically on network-mapper, cmdb and lms, using Β§13's check rather than a scroll position:

tapped contents item 5
  elementFromPoint(180,400) before: P     after: P    ← the same node
  => πŸ”΄ inert

⭐ §26's shape without the hover: a control that is present, looks live, and is connected to nothing. It reads as "this app doesn't do that" rather than as a bug, so in seventeen guides across sixteen shipped modules nobody has ever reported it.

Fixed once in mobile.js for all seventeen, gated on mq.matches, delegated. After:

scrollTo calls: #helpMain top=3458 | .help-container top=3533
  .help-container scrollTop = 3533
  elementFromPoint before: P  ->  after: DIV.help-card    βœ…

The first call is the page's own, still doing nothing; the second is this layer's, doing the work. The spy and the strip‑follow are the same fix β€” a highlight that never moves is the other half of the same broken control.


⚠️ Three probe faults, and only one real bug found by looking

Β§30's addendum says distrust a new probe on its first red. Three of this round's four reds were the probe:

  1. The palette sheet "did not open" β€” attribute flipped, panel still at x=-295. --virtual-time-budget makes setTimeout fire instantly on a virtual clock while CSS transitions run on the animation clock, so a 900ms wait was 0ms of transition. With transition: none forced: x=0, visibility: visible, elementFromPoint at its centre returning .nm-palette-body, 8 tiles, scrim closing it.
  2. The meta row "was clipped" in a screenshot. Y‑positions settled it: span2 y=218 against y=199 for the other two, row 51px tall β€” it wraps. I misread a 360px image.
  3. The list page "was 460px wide and clipped" in a screenshot β€” search box and New diagram running off. That was Β§8's Windows clamp: the redirect‑to‑the‑real‑page route renders at ~500px and crops to the 360px window. With the scroller filter off (Β§18), zero elements exceeded 360px. Screenshot through a 360‑pinned iframe, which is what Β§8 prescribes and what the working shots used.

πŸ”΄ And the one the screenshot did find

Giving the card's action row flex: 1 1 100% made its button full width and 38px tall. The only action on that card is Delete, and the card's entire surface is the tap target for opening the diagram.

⭐ Every number said it was an improvement: 22px β†’ 38px is a tap‑target floor being met, and a tap‑target check would have passed it. Size is not the only property a control has β€” a danger button that grows also becomes easier to hit by accident, and on a card that is itself a button, that is the property that matters.

Thumb‑sized in height, natural width, still pushed right by the row's own space-between. That is now four rounds in a row where the thing that mattered most was found by looking rather than measuring.


How it was verified

  • All three pages contained at 360Γ—740, measured by box rather than document (Β§30), with the scroller filter off as well as on.
  • Each has a scroller that reaches its end (Β§28): list 747 of 747, canvas 4517 of 4517, guide 8480 of 8480.
  • The palette sheet driven open and shut β€” x=0, 295px, visible, elementFromPoint returning the panel not the scrim, 8 tiles, hint display: none, scrim tap closing it.
  • A node tapped with a synthesised mousedown, as a real tap produces: panel open, 360 wide, top=333; the selected node moved y=662 β†’ y=233, above the sheet and on screen; the detail body scrolls and reaches its end.
  • The branding dialogue driven open: 360Γ—740 sheet, grid stacked, inputs at 16px. The versions dropdown at x=0 w=360 top=629 β€” a bottom sheet.
  • Desktop control at 1100Γ—900, and it is byte‑identical to the pre‑change baseline β€” .nm-editor-title-area w=0, .nm-editor-actions w=1206 right=1242, .nm-palette w=240, .nm-canvas w=859, .nm-card 340Γ—170, action button 50Γ—22. The injected palette button is display: none.
  • Process Mapper measured as a positive control at 360 and 1100 β€” unchanged at both, and [class*="nm-"] returns 0 there.
  • The help fix desktop‑controlled on three modules: only the page's own #helpMain call fires, the mobile listener no‑ops, the page still scrolls.
  • Β§17 caught a stray */ introduced by editing a comment β€” braces balanced, and the rules after it were being dropped. The awk check is worth running after every comment edit, not just at the end.
  • The Β§25 audit: the module's own diff is nine lines β€” three <link>, three <body> markers, three <script> β€” and every one of the other 91 pages changed only its ?v=. Zero desktop changes.

⭐ And then Ed asked for the drag anyway (#1468)

"on mobile version can you make it so you can drag something from the cmdb classes panel onto the diagram"

Which makes it the right thing to build. The objection above was not withdrawn, though, so what got built is all three halves, because the question that settles whether placement is a feature or a trap is what can a phone do about a mistake:

gesture ends by dispatching
place long-press a class tile, drag onto the map, release a real drop DragEvent carrying a DataTransfer
move long-press a node, drag it mousedown on the node, then mousemove / mouseup
delete the node's own sheet gains a Delete keydown { key: 'Delete' }

πŸ”΄ The second and third are not extras. deleteSelectedNode() is bound to Delete/Backspace and nothing else, and a phone has neither β€” so without them a node of the wrong class, or in the wrong place, was permanent. A feature whose undo needs a keyboard is a one-way door.

⭐ Not one line of network-mapper.js changed

Each gesture ends by dispatching the event the module already listens for, so snap-to-grid, the model-coordinate maths (scroll offset, zoom, the half-icon centring), the read-only guard, the dirty flag, the connector cleanup and autosave all run exactly as they do under a mouse β€” including guards this layer would otherwise have had to duplicate and keep in step. Β§1's wrap, don't edit, applied to a feature rather than a layout.

deleteSelectedNode and openObjectPicker are not on window.NM, which turned out to be the useful constraint: reaching them through the module's own event handlers is better than exporting them would have been.

⚠️ And it deliberately does NOT use the HTML5 drag §22 pointed at

Β§22 says the palette's draggable="true" drag would probably work on touch. It is not what this uses, because the palette is a sheet covering the canvas β€” it has to get out of the way mid-gesture, and a native drag will not survive its source element sliding off screen. A hand-rolled touch drag can close the sheet and carry on. (touchmove/touchend are bound to document, not to the tile, for exactly that reason.)

⭐ A side benefit worth banking: §22 says you cannot settle a drag headlessly, and that is true of the NATIVE one. A hand-rolled touch drag can be driven with synthetic TouchEvents, so all three gestures were exercised end to end before shipping. It is still not a device pass.

The long press, and why it is 320ms

A short drag starting on the canvas is a pan; one starting on the palette is a scroll of the class list. Both had to stay exactly as they were. The gesture only becomes a drag after 320ms without the finger travelling more than 10px β€” the browser's own long-press-to-drag behaviour β€” and travelling first hands the gesture straight back. Verified: a 40px move before the timer left no ghost.

e.preventDefault() in a non-passive touchmove does double duty: it stops the canvas panning under the drag, and it suppresses the compatibility mouse events the browser would otherwise synthesise at touchend, which would arrive after ours and re-select.

πŸ”΄ The iOS guard a headless run cannot test for

A long press is also the system gesture for the selection callout and magnifier. Both elements already carried user-select: none, which is a different property and does not suppress the callout on its own β€” so -webkit-touch-callout: none is set on the tiles, the nodes and the ghost.

How #1468 was verified

Driven end to end with synthetic TouchEvents on a throwaway diagram, because autosave is on and the dev database is real data:

place   long press -> ghost present, sheet closed; ghost follows to (200,420);
        elementFromPoint under the finger = the canvas; release -> object
        picker OPEN with 6 objects; choosing one -> 1 node        βœ…
move    long press -> .nm-touch-drag applied; drag -> left/top
        180,160 -> 280,220, snapped to the 20px grid              βœ…
delete  tap -> sheet open, Delete 73x42 at x=271 (thumb-sized,
        NOT full width); click -> 0 nodes, sheet closed           βœ…
guard   40px of travel before the timer -> no ghost, still a scroll βœ…
desktop 1100px: both injected buttons display:none, a long-press
        drag produces no ghost, no picker, no node                βœ…

⚠️ Synthetic touch proves the handler logic and the module integration, not a real finger. iOS Safari's own gesture arbitration β€” whether a scroll it has already begun can still be cancelled β€” is the one thing left for a device pass.

⬜ Known and not fixed

  • Drawing connectors. Still mousedown/mousemove on an edge handle, still desktop-only. The handles are visible on touch (they follow .selected), so this is a gesture that is missing rather than a control that is hidden.
  • .nm-editor-actions overflows a 1100px desktop window (1206px of toolbar). Pre‑existing, identical before and after this round, and not a mobile question β€” recorded here because the baseline measured it.
  • Present mode fits the whole diagram to the viewport and clips, so on a phone it is an honest but very small picture with no way to pan. Left alone; the normal canvas is the read path.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally