PageSourceSearch

https://www.mipui.net/app/interactions.js

js mipui.net collected 2026-10-03 20:39:15 UTC 24,402 bytes, 697 lines download raw bytes

1const isTouchDevice =
2    window.matchMedia(
3        '(any-hover: none) and (orientation: portrait)').matches;
4const cached = {};
5let mapContainer;
6const mapResizeButtons = [
7  {name: 'add-column-right', pos: 'right', place: 0},
8  {name: 'remove-column-right', pos: 'right', place: 1},
9  {name: 'add-row-bottom', pos: 'bottom', place: 0},
10  {name: 'remove-row-bottom', pos: 'bottom', place: 1},
11  {name: 'add-column-left', pos: 'left', place: 0},
12  {name: 'remove-column-left', pos: 'left', place: 1},
13  {name: 'add-row-top', pos: 'top', place: 0},
14  {name: 'remove-row-top', pos: 'top', place: 1},
15];
16let refreshMapResizeButtonLocationsTimeout = null;
17
18function getCached(obj, fieldName) {
19  const cachedObj = cached[obj] || {};
20  let result = cachedObj[fieldName];
21  if (!result) {
22    result = obj[fieldName];
23    cached[obj] = cachedObj;
24    cachedObj[fieldName] = result;
25  }
26  return result;
27}
28
29function setAndCache(obj, fieldName, value) {
30  obj[fieldName] = value;
31  if (!cached[obj]) cached[obj] = {};
32  cached[obj][fieldName] = value;
33}
34
35function incrementAndCache(obj, fieldName, valueDiff) {
36  const oldValue = obj[fieldName];
37  const newValue = oldValue + valueDiff;
38  setAndCache(obj, fieldName, newValue);
39}
40
41function invalidateCached(obj, fieldName) {
42  if (!cached[obj]) return;
43  cached[obj][fieldName] = null;
44}
45
46function handleResizeEvent() {
47  invalidateCached(mapContainer, 'offsetWidth');
48  invalidateCached(mapContainer, 'offsetHeight');
49}
50
51function handleKeyDownEvent(keyDownEvent) {
52  if (state.dialog) {
53    switch (keyDownEvent.key) {
54      case 'Escape': state.dialog.cancel(); break;
55      case 'Enter': state.dialog.accept(); break;
56      default:
57        // Ignore.
58        return;
59    }
60  }
61  if (state.isReadOnly()) return;
62  if (keyDownEvent.ctrlKey) {
63    switch (keyDownEvent.key) {
64      case 'z':
65        state.opCenter.undo();
66        state.opCenter.recordOperationComplete();
67        keyDownEvent.preventDefault();
68        break;
69      case 'y':
70        state.opCenter.redo();
71        state.opCenter.recordOperationComplete();
72        keyDownEvent.preventDefault();
73        break;
74      case 'x':
75        if (state.gesture instanceof SelectGesture) {
76          state.gesture.cut();
77          keyDownEvent.preventDefault();
78        }
79        break;
80      case 'c':
81        if (state.gesture instanceof SelectGesture) {
82          state.gesture.copy();
83          keyDownEvent.preventDefault();
84        }
85        break;
86      case 'v': {
87        state.gesture = new PasteGesture();
88        keyDownEvent.preventDefault();
89        break;
90      }
91    }
92  } else {
93    switch (keyDownEvent.key) {
94      case 'Escape': {
95        state.menu.setToInitialSelection();
96        keyDownEvent.preventDefault();
97        break;
98      }
99      case 'Delete':
100        if (state.gesture instanceof SelectGesture) {
101          state.gesture.deleteSelection();
102          keyDownEvent.preventDefault();
103        }
104        break;
105      default:
106        // Delegate this to the menu.
107        state.menu.keydown(keyDownEvent.key);
108        break;
109    }
110  }
111}
112
113function updateMapTransform(shouldRefreshMapResizeButtonLocations) {
114  const nav = state.navigation;
115  const theMap = document.getElementById('theMap');
116  theMap.style.transform = `scale(${nav.scale})`;
117  // For proper container sizing:
118  invalidateCached(mapContainer, 'offsetWidth');
119  invalidateCached(mapContainer, 'offsetHeight');
120  const mapWidth = getCached(mapContainer, 'offsetWidth');
121  const mapHeight = getCached(mapContainer, 'offsetHeight');
122  const mapFrame = document.getElementById('mapFrame');
123  mapFrame.style.width =
124      (state.theMap.mapWidth * nav.scale + mapWidth) + 'px';
125  mapFrame.style.height =
126      (state.theMap.mapHeight * nav.scale + mapHeight) + 'px';
127  theMap.style.left = (mapWidth / 2) + 'px';
128  theMap.style.top = (mapHeight / 2) + 'px';
129  if (shouldRefreshMapResizeButtonLocations) {
130    refreshMapResizeButtonLocations();
131  }
132}
133
134let useWheelForZooming = true;
135function handleWheelEvent(wheelEvent) {
136  wheelEvent.preventDefault();
137  if (wheelEvent.deltaX > 0 || wheelEvent.ctrlKey) {
138    // The first time a horizontal scroll is encountered, or the first time
139    // ctrl is held during a scroll, we assume trackpad / touchpad and stop
140    // using wheel for zooming.
141    useWheelForZooming = false;
142  }
143  if (useWheelForZooming || wheelEvent.ctrlKey) {
144    zoom(wheelEvent, !useWheelForZooming);
145  } else {
146    pan(-wheelEvent.deltaX, -wheelEvent.deltaY);
147  }
148}
149
150function zoom(wheelEvent, incremental = false) {
151  const nav = state.navigation;
152
153  // Calculate distance from mapContainer edges.
154  let distanceFromLeft = wheelEvent.x + getCached(mapContainer, 'scrollLeft');
155  let distanceFromTop = wheelEvent.y + getCached(mapContainer, 'scrollTop');
156  distanceFromLeft -= getCached(mapContainer, 'offsetWidth') / 2;
157  distanceFromTop -= getCached(mapContainer, 'offsetHeight') / 2;
158
159  let scaleDiff = 1.0;
160  if (wheelEvent.deltaY > 0 && nav.scale > 0.3) {
161    scaleDiff = -0.2;
162  } else if (wheelEvent.deltaY < 0 && nav.scale < 5.9) {
163    scaleDiff = 0.2;
164  } else {
165    return;
166  }
167  if (incremental) {
168    scaleDiff *= 0.3 * nav.scale;
169  }
170  const growth = scaleDiff / nav.scale;
171  nav.scale += scaleDiff;
172
173  pan(-growth * distanceFromLeft, -growth * distanceFromTop);
174  updateMapTransform(true);
175}
176
177function handleMouseDownEvent(mouseEvent) {
178  if (mouseEvent.buttons == 4) {
179    // Don't propagate middle mouse click, to avoid Chrome's "mouse nav" mode.
180    mouseEvent.stopPropagation();
181    mouseEvent.preventDefault();
182  }
183}
184
185function handleMouseMoveEvent(mouseEvent) {
186  if (mouseEvent.movementX == 0 && mouseEvent.movementY == 0) return;
187  if (mouseEvent.buttons == 4) {
188    // Middle button is pan.
189    pan(mouseEvent.movementX, mouseEvent.movementY);
190  }
191}
192
193let scrollCallRequested = false;
194let prevCellKey = null;
195function handleScrollEvent(event) {
196  if (scrollCallRequested) return;
197  scrollCallRequested = true;
198  event.stopPropagation();
199  window.requestAnimationFrame(() => {
200    invalidateCached(mapContainer, 'scrollLeft');
201    invalidateCached(mapContainer, 'scrollTop');
202    refreshMapResizeButtonLocations();
203    scrollCallRequested = false;
204
205    if (!isTouchDevice) return;
206    const theMap = document.getElementById('theMap');
207    invalidateCached(mapContainer, 'offsetWidth');
208    invalidateCached(mapContainer, 'offsetHeight');
209    const currentCellKey = getCellKey(
210        getCached(mapContainer, 'scrollLeft') +
211        getCached(mapContainer, 'offsetWidth') / 2 -
212        getCached(theMap, 'offsetLeft'),
213        getCached(mapContainer, 'scrollTop') +
214        getCached(mapContainer, 'offsetHeight') / 2 -
215        getCached(theMap, 'offsetTop'));
216    if (!prevCellKey || prevCellKey != currentCellKey) {
217      if (prevCellKey) {
218        const prevCell = state.theMap.cells.get(prevCellKey);
219        if (prevCell && state.gesture) {
220          state.gesture.stopHover();
221        }
222      }
223      const currentCell = state.theMap.cells.get(currentCellKey);
224      if (currentCell && state.gesture) {
225        if (isActionClicked) {
226          state.gesture.continueGesture(currentCell);
227        } else {
228          state.gesture.startHover(currentCell);
229        }
230      }
231      prevCellKey = currentCellKey;
232    }
233  });
234}
235
236function pan(x, y) {
237  incrementAndCache(mapContainer, 'scrollLeft', -x);
238  incrementAndCache(mapContainer, 'scrollTop', -y);
239}
240
241function calcDistance(x1, y1, x2, y2) {
242  return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
243}
244
245function calcCenter(x1, y1, x2, y2) {
246  return {x: (x1 + x2) / 2, y: (y1 + y2) / 2};
247}
248
249let currentPinch = null;
250let prevSingleTouchPos = null;
251function handleTouchStartEvent(touchEvent) {
252  if (touchEvent.touches.length == 2) {
253    if (touchEvent.touches[0].target.classList.contains('action-pane') ||
254        touchEvent.touches[1].target.classList.contains('action-pane')) {
255      return;
256    }
257    currentPinch = {
258      initialDistance:
259          calcDistance(
260              touchEvent.touches[0].pageX,
261              touchEvent.touches[0].pageY,
262              touchEvent.touches[1].pageX,
263              touchEvent.touches[1].pageY),
264      center:
265          calcCenter(
266              touchEvent.touches[0].pageX,
267              touchEvent.touches[0].pageY,
268              touchEvent.touches[1].pageX,
269              touchEvent.touches[1].pageY),
270      initialScale: state.navigation.scale,
271    };
272  } else {
273    const mapContainerTouches = getMapContainerTouches(touchEvent.touches);
274    if (touchEvent.touches.length > 1 && mapContainerTouches.length == 1) {
275      prevSingleTouchPos = {
276        x: mapContainerTouches[0].pageX,
277        y: mapContainerTouches[0].pageY,
278      };
279    }
280  }
281}
282
283let pinchCallRequested = false;
284let panRequested = false;
285function handleTouchMoveEvent(touchEvent) {
286  if (currentPinch && touchEvent.touches.length == 2 && !pinchCallRequested) {
287    pinchCallRequested = true;
288    window.requestAnimationFrame(() => {
289      pinchCallRequested = false;
290      const center =
291          calcCenter(
292              touchEvent.touches[0].pageX,
293              touchEvent.touches[0].pageY,
294              touchEvent.touches[1].pageX,
295              touchEvent.touches[1].pageY);
296      const distance =
297          calcDistance(
298              touchEvent.touches[0].pageX,
299              touchEvent.touches[0].pageY,
300              touchEvent.touches[1].pageX,
301              touchEvent.touches[1].pageY);
302
303      // First, scroll to 0,0
304      const oldScroll = {
305        x: getCached(mapContainer, 'scrollLeft'),
306        y: getCached(mapContainer, 'scrollTop'),
307      };
308      setAndCache(mapContainer, 'scrollLeft', 0);
309      setAndCache(mapContainer, 'scrollTop', 0);
310      console.log(oldScroll);
311
312      // Apply zoom.
313      const newScale =
314          currentPinch.initialScale * (distance / currentPinch.initialDistance);
315      //const scaleDiff = newScale - state.navigation.scale;
316      //const growth = scaleDiff / state.navigation.scale;
317      //const scaleFactor = newScale / state.navigation.scale;
318      state.navigation.scale = newScale;
319      updateMapTransform(false);
320
321      // Finally, scroll back.
322      const centerDiff = {
323        x: center.x - currentPinch.center.x,
324        y: center.y - currentPinch.center.y,
325      };
326      setAndCache(
327          mapContainer, 'scrollLeft',
328          oldScroll.x + centerDiff.x * newScale);
329      setAndCache(
330          mapContainer, 'scrollTop',
331          oldScroll.y + centerDiff.y * newScale);
332
333      currentPinch.center = center;
334      updateMapTransform(true);
335    });
336  } else {
337    if (!panRequested) {
338      // If exactly one touch is over the map container, use it to pan.
339      const mapContainerTouches = getMapContainerTouches(touchEvent.touches);
340      if (touchEvent.touches.length > 1 && mapContainerTouches.length == 1) {
341        panRequested = true;
342        window.requestAnimationFrame(() => {
343          const pos = {
344            x: mapContainerTouches[0].pageX,
345            y: mapContainerTouches[0].pageY,
346          };
347          if (prevSingleTouchPos) {
348            pan(pos.x - prevSingleTouchPos.x, pos.y - prevSingleTouchPos.y);
349          }
350          prevSingleTouchPos = pos;
351          panRequested = false;
352        });
353      }
354    }
355  }
356}
357
358function getMapContainerTouches(touchList) {
359  return Array.from(touchList).filter(
360      touch => touch.target.id == 'mapContainer' ||
361          touch.target.classList.contains('grid-cell'));
362}
363
364function handleTouchEndEvent() {
365  currentPinch = null;
366  prevSingleTouchPos = null;
367}
368
369function getCellKey(x, y) {
370  const nav = state.navigation;
371  const cellSize = state.theMap.cellWidth * nav.scale;
372  const borderSize = state.theMap.dividerWidth * nav.scale;
373  const cellAndBorderSize = cellSize + borderSize;
374
375  const toRow =
376      Math.floor(y / cellAndBorderSize) + state.getProperty(pk.firstRow);
377  const fromRow = (y % cellAndBorderSize) / borderSize > 1 ? toRow : toRow - 1;
378
379  const toCol =
380      Math.floor(x / cellAndBorderSize) + state.getProperty(pk.firstColumn);
381  const fromCol = (x % cellAndBorderSize) / borderSize > 1 ? toCol : toCol - 1;
382
383  const key = fromRow != toRow || fromCol != toCol ?
384    CellMap.dividerCellKey(fromRow, fromCol, toRow, toCol) :
385    CellMap.primaryCellKey(fromRow, fromCol);
386  return key;
387}
388
389function resizeGridBy(
390    firstColumnDiff, lastColumnDiff, firstRowDiff, lastRowDiff) {
391  if (state.isReadOnly()) return;
392  // First, complete pending ops.
393  state.opCenter.recordOperationComplete();
394  deleteContentThatWillBeOutsideTheMap(
395      firstColumnDiff, lastColumnDiff, firstRowDiff, lastRowDiff);
396  // Update the state's grid data.
397  [
398    {diff: firstColumnDiff, prop: pk.firstColumn},
399    {diff: lastColumnDiff, prop: pk.lastColumn},
400    {diff: firstRowDiff, prop: pk.firstRow},
401    {diff: lastRowDiff, prop: pk.lastRow},
402  ].forEach(({diff, prop}) => {
403    if (diff != 0) {
404      state.setProperty(prop, state.getProperty(prop) + diff, true);
405    }
406  });
407  // Update transform so that elements on the viewport won't move around.
408  if (firstColumnDiff != 0 || firstRowDiff != 0) {
409    // The below will trigger a scroll event, though. To prevent that event from
410    // starting a resize button refresh flow, we set the button refresh timer id
411    // here to be some invalid number, if it's not defined.
412    if (refreshMapResizeButtonLocationsTimeout == null) {
413      refreshMapResizeButtonLocationsTimeout = -1;
414    }
415    const nav = state.navigation;
416    incrementAndCache(mapContainer, 'scrollLeft',
417        -firstColumnDiff * nav.scale *
418        (state.theMap.cellWidth + state.theMap.dividerWidth));
419    incrementAndCache(mapContainer, 'scrollTop',
420        -firstRowDiff * nav.scale *
421        (state.theMap.cellHeight + state.theMap.dividerHeight));
422  }
423  updateMapTransform(false);
424  state.opCenter.recordOperationComplete(true);
425}
426
427function cellIsOutsideBoundaries(
428    cell, firstColumn, lastColumn, firstRow, lastRow) {
429  return cell.column < firstColumn - 0.5 ||
430      cell.column >= lastColumn ||
431      cell.row < firstRow - 0.5 ||
432      cell.row >= lastRow;
433}
434
435function deleteContentThatWillBeOutsideTheMap(
436    firstColumnDiff, lastColumnDiff, firstRowDiff, lastRowDiff) {
437  const newFirstColumn = state.getProperty(pk.firstColumn) + firstColumnDiff;
438  const newLastColumn = state.getProperty(pk.lastColumn) + lastColumnDiff;
439  const newFirstRow = state.getProperty(pk.firstRow) + firstRowDiff;
440  const newLastRow = state.getProperty(pk.lastRow) + lastRowDiff;
441  const cellsToDelete = new Set();
442  const cellLayersToDelete = new Map();
443  ct.children.forEach(layer => cellLayersToDelete.set(layer, new Set()));
444  state.theMap.cells.forEach(cell => {
445    // If the cell is outside the map, delete it.
446    if (cellIsOutsideBoundaries(
447        cell, newFirstColumn, newLastColumn, newFirstRow, newLastRow)) {
448      cellsToDelete.add(cell);
449    }
450    // If the cell has an endcell which is outside the map for a given layer,
451    // delete the content for that layer.
452    ct.children.forEach(layer => {
453      const content = cell.getLayerContent(layer);
454      if (!content) return;
455      const endCellKey = content[ck.endCell];
456      if (!endCellKey) return;
457      const endCell = state.theMap.cells.get(endCellKey);
458      if (cellIsOutsideBoundaries(
459          endCell, newFirstColumn, newLastColumn, newFirstRow, newLastRow)) {
460        cellLayersToDelete.get(layer).add(cell);
461      }
462    });
463  });
464  state.theMap.cells.forEach(cell => {
465    ct.children.forEach(layer => {
466      // Delete if it was deleted or its endcell was deleted.
467      let deleteLayerContent =
468          cellsToDelete.has(cell) || cellLayersToDelete.get(layer).has(cell);
469      // Delete if its startcell was deleted.
470      if (!deleteLayerContent) {
471        const content = cell.getLayerContent(layer);
472        if (content && content[ck.startCell]) {
473          const startCell = state.theMap.cells.get(content[ck.startCell]);
474          if (cellsToDelete.has(startCell) ||
475              cellLayersToDelete.get(layer).has(startCell)) {
476            deleteLayerContent = true;
477          }
478        }
479      }
480      if (deleteLayerContent) {
481        cell.setLayerContent(layer, null, true);
482      }
483    });
484  });
485}
486
487function resetView() {
488  const nav = state.navigation;
489  nav.scale = 1.0;
490  updateMapTransform(false);
491  const theMap = document.getElementById('theMap');
492  setAndCache(mapContainer, 'scrollLeft', theMap.clientWidth / 2);
493  setAndCache(mapContainer, 'scrollTop', theMap.clientHeight / 2);
494  updateMapTransform(true);
495}
496
497function resetGrid() {
498  if (state.isReadOnly()) return;
499  state.opCenter.recordOperationComplete();
500  state.theMap.resetToDefault();
501  [pk.firstColumn, pk.firstRow, pk.lastColumn, pk.lastRow].forEach(property => {
502    state.setProperty(property, null, true);
503  });
504  resetView();
505  // Force a map rewrite with the operation.
506  state.opCenter.recordOperationComplete(true);
507}
508
509function refreshMapResizeButtonLocations() {
510  if (refreshMapResizeButtonLocationsTimeout) {
511    clearTimeout(refreshMapResizeButtonLocationsTimeout);
512  } else {
513    mapResizeButtons.forEach(button => {
514      if (!button.element) {
515        button.element =
516            document
517                .getElementsByClassName('map-resize-button-' + button.name)[0];
518      }
519      document.getElementById('app').classList.add('transforming-map');
520    });
521  }
522  refreshMapResizeButtonLocationsTimeout = setTimeout(() => {
523    const theMap = document.getElementById('theMap');
524    document.getElementById('app').classList.remove('transforming-map');
525    const nav = state.navigation;
526    const left = theMap.offsetLeft;
527    const right = left + (theMap.offsetWidth * nav.scale);
528    const top = theMap.offsetTop;
529    const bottom = top + (theMap.offsetHeight * nav.scale);
530    const rect = {left, right, top, bottom};
531    mapResizeButtons.forEach(button => {
532      if (!button.element) return;
533      let x = clamp(rect.left + 70, getCached(mapContainer, 'scrollLeft') +
534          getCached(mapContainer, 'offsetWidth') / 2, rect.right - 70);
535      let y = clamp(rect.top + 70, getCached(mapContainer, 'scrollTop') +
536          getCached(mapContainer, 'offsetHeight') / 2, rect.bottom - 70);
537      let offsetX = button.place == 0 ? -70 : 40;
538      let offsetY = offsetX;
539      switch (button.pos) {
540        case 'right': x = rect.right; offsetX = 50; break;
541        case 'bottom': y = rect.bottom; offsetY = 50; break;
542        case 'left': x = rect.left; offsetX = -70; break;
543        case 'top': y = rect.top; offsetY = -70; break;
544      }
545      button.element.style.left = (x + offsetX) + 'px';
546      button.element.style.top = (y + offsetY) + 'px';
547      refreshMapResizeButtonLocationsTimeout = null;
548    });
549    theMap.classList.add('resized-map');
550    setTimeout(() => { theMap.classList.remove('resized-map'); }, 100);
551  }, 1000);
552}
553
554let isActionClicked = false;
555function switchToMobileMode() {
556  const scale = 1.6;
557  const app = document.getElementById('app');
558  app.style.transform = `scale(${scale})`;
559  app.style.width = (100 / scale) + '%';
560  app.style.height = (100 / scale) + '%';
561  createAndAppendDivWithClass(mapContainer, 'mobile-cursor');
562  invalidateCached(mapContainer, 'offsetWidth');
563  invalidateCached(mapContainer, 'offsetHeight');
564  updateMapTransform(true);
565  const actionPane =
566      createAndAppendDivWithClass(
567          document.body, 'action-pane');
568  actionPane.textContent = 'Tap here to Apply.';
569  actionPane.addEventListener('touchstart', e => {
570    e.stopPropagation();
571    if (state.gesture) {
572      isActionClicked = true;
573      state.gesture.startGesture();
574    }
575  });
576  actionPane.addEventListener('touchend', e => {
577    e.stopPropagation();
578    if (state.gesture) {
579      state.gesture.stopGesture();
580      isActionClicked = false;
581      if (prevCellKey) {
582        const prevCell = state.theMap.cells.get(prevCellKey);
583        state.gesture.startHover(prevCell);
584      }
585    }
586  });
587  actionPane.addEventListener('touchmove', e => { e.stopPropagation(); });
588  actionPane.onmousedown = e => e.stopPropagation();
589  actionPane.onmousemove = e => e.stopPropagation();
590  actionPane.onmouseup = e => e.stopPropagation();
591}
592
593function numberRooms(kind, variation) {
594  let num = 1;
595  const roomsToNumber = [];
596  getRooms().forEach(room => {
597    const existingContent = room.centerCell.getLayerContent(ct.text);
598    if (existingContent) {
599      if (!existingContent[ck.startCell] && !existingContent[ck.endCell]) {
600        // There's already a single-cell text content at the center. Maybe
601        // that's a previous number?
602        const existingNum = Number.parseInt(existingContent[ck.text]);
603        if (existingNum != NaN) {
604          num = Math.max(num, existingNum + 1);
605        }
606      }
607      return;
608    }
609    roomsToNumber.push(room);
610  });
611  roomsToNumber.forEach(room => {
612    room.centerCell.setLayerContent(ct.text, {
613      [ck.kind]: kind.id,
614      [ck.variation]: variation.id,
615      [ck.text]: num++,
616    }, true);
617  });
618  state.opCenter.recordOperationComplete();
619}
620
621function getRooms() {
622  const rooms = [];
623  state.theMap.cells.forEach(cell => {
624    if (cell.role != 'primary') return;
625    if (cell.hasLayerContent(ct.walls)) return;
626    if (rooms.some(room => room.cells.has(cell))) return;
627    const newRoom = createRoom(cell);
628    if (newRoom) rooms.push(newRoom);
629  });
630  // Filter out rooms that contain other rooms.
631  return rooms.filter(room => !rooms.some(otherRoom =>
632    room != otherRoom && room.minCol <= otherRoom.minCol &&
633        room.minRow <= otherRoom.minRow &&
634        room.maxCol >= otherRoom.maxCol &&
635        room.maxRow >= otherRoom.maxRow));
636}
637
638function createRoom(seed) {
639  const cells = new Set([seed]);
640  let front = new Set([seed]);
641  while (front.size > 0) {
642    const newFront = new Set();
643    front.forEach(cell => {
644      ['top', 'bottom', 'left', 'right'].forEach(dir => {
645        const neighbors = cell.getNeighbors(dir);
646        if (!neighbors) return;
647        const neighborPrimary = neighbors.cells[0];
648        if (!neighborPrimary ||
649            neighborPrimary.hasLayerContent(ct.walls) ||
650            newFront.has(neighborPrimary) ||
651            cells.has(neighborPrimary)) {
652          return;
653        }
654        const neighborDivider = neighbors.dividerCell;
655        if (neighborPrimary.hasLayerContent(ct.walls)) return;
656        // Check corners
657        const cornerDirs = (dir == 'left' || dir == 'right') ?
658          ['top', 'bottom'] : ['left', 'right'];
659        if (cornerDirs
660            .map(cornerDir => neighborDivider.getNeighbor(cornerDir, true))
661            .filter(corner => !!corner)
662            .every(corner => corner.hasLayerContent(ct.walls))) {
663          return;
664        }
665        cells.add(neighborPrimary);
666        newFront.add(neighborPrimary);
667      });
668    });
669    front = newFront;
670  }
671  if (cells.size <= 1) return null;
672  let minRow = Number.POSITIVE_INFINITY;
673  let minCol = Number.POSITIVE_INFINITY;
674  let maxRow = Number.NEGATIVE_INFINITY;
675  let maxCol = Number.NEGATIVE_INFINITY;
676  cells.forEach(cell => {
677    minRow = Math.min(cell.row, minRow);
678    minCol = Math.min(cell.column, minCol);
679    maxRow = Math.max(cell.row, maxRow);
680    maxCol = Math.max(cell.column, minCol);
681  });
682  // Find the cell closest to the center.
683  const centerRow = Math.floor((minRow + maxRow) / 2);
684  const centerCol = Math.floor((minCol + maxCol) / 2);
685  let centerCell = null;
686  let minDistance = Number.POSITIVE_INFINITY;
687  for (const cell of cells) {
688    const distance = Math.pow(cell.row - centerRow, 2) +
689        Math.pow(cell.column - centerCol, 2);
690    if (distance < minDistance) {
691      minDistance = distance;
692      centerCell = cell;
693      if (distance == 0) break;
694    }
695  }
696  return {cells, minRow, minCol, maxRow, maxCol, centerCell};
697}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.