'use strict'

const snabbdom = require('snabbdom')
const { h } = snabbdom
const patch = snabbdom.init([
  snabbdom.attributesModule,
  snabbdom.propsModule,
  snabbdom.classModule,
  snabbdom.eventListenersModule,
  snabbdom.styleModule,
])

// Helpers

function preventDefault (event) {
  event.preventDefault()
}

// returns the SVG coordinates of the pointer for an event
function eventSVGPoint (event, svg = event.currentTarget.ownerSVGElement || event.currentTarget) {
  const point = svg.createSVGPoint()
  point.x = event.clientX
  point.y = event.clientY
  return point.matrixTransform(svg.getScreenCTM().inverse())
}

function repeat (n, el) {
  const arr = new Array(n)
  for (let i = 0; i < n; i++) arr[i] = el
  return arr
}

function removeEnd (str, remove) {
  if (str.endsWith(remove)) {
    return str.slice(0, -remove.length)
  }
  return str
}

//  Constants

const VIEW_WELCOME = 0
const VIEW_EDITOR = 1
const VIEW_PLAYBACK = 2
const VIEW_INTERACTIVE = 3

const MAX_A = 1000
const MAX_F = 20

const FRAMES_PER_ROUND = 5

// State

let viewWidth
let viewHeight
let activeView = VIEW_WELCOME
let mapViewScale
let mapViewX
let mapViewY

const dragEnteredElements = new Set()
let draggingOver = false

let fileName = 'Untitled map'
let fileHandle
let stateChanged = false
let hasFileUrl = false
let urlFetchError = null

let map
let game
let isMapValid

let playbackRound
let playbackRoundFrame

let playbackPlaying
let playbackTimeout = 0

let selectedNodes
let dragStartPt
let dragEndPt
let altPressed = false
let addMode
let addPt
let addEdgeStartNode
let addEdgeEndNode
let addEdgeClick

let dragPt
let nodeDrag = false
let primaryPointerId = null
const mapTouches = new Map()
let lastTwoFingerParams
let oldSelectedNodes

// File handling

const MAP_EXTENSION = '.json'
const LOG_EXTENSION = '.json'

document.body.addEventListener('dragenter', event => {
  event.preventDefault()
  if (event.dataTransfer != null && event.dataTransfer.types.includes('Files')) {
    if (dragEnteredElements.size === 0) {
      draggingOver = true
      render()
    }
    dragEnteredElements.add(event.target)
  }
})
document.body.addEventListener('dragleave', event => {
  dragEnteredElements.delete(event.target)
  if (dragEnteredElements.size === 0) {
    draggingOver = false
    render()
  }
})

document.documentElement.addEventListener('dragover', preventDefault)
document.documentElement.addEventListener('drop', async event => {
  event.preventDefault()
  dragEnteredElements.clear()
  draggingOver = false
  render()

  if (event.dataTransfer == null) return
  for (const item of event.dataTransfer.items) {
    if (item.kind === 'file') {
      if (item.getAsFileSystemHandle != null) {
        const handle = await item.getAsFileSystemHandle()
        if (handle.kind === 'file') {
          const file = await handle.getFile()
          openedFile(file, handle)
          break
        }
      } else {
        const file = item.getAsFile()
        if (file != null) {
          openedFile(file, null)
          break
        }
      }
    }
  }
})

function fetchError (error) {
  urlFetchError = error.message
  render()
}

const URL_PREFIX = '#url='
if (location.hash.startsWith(URL_PREFIX)) {
  hasFileUrl = true
  const fileUrl = location.hash.slice(URL_PREFIX.length)
  fetch(fileUrl).then(response => {
    if (!response.ok) {
      fetchError(new Error(`HTTP ${response.status}`))
    } else {
      let name = decodeURIComponent(fileUrl.slice(fileUrl.lastIndexOf('/') + 1))
      const nameHeader = response.headers.get('Content-Disposition')
      if (nameHeader != null) {
        const match = /filename=(.*)/ig.exec(nameHeader)
        if (match) name = match[1]
      }
      response.text().then(
        text => openedFileText(text, name, null),
        error => fetchError(error))
    }
  }, error => fetchError(error))
}

const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'application/json,.json'
fileInput.addEventListener('change', () => {
  if (fileInput.files.length >= 1) {
    const file = fileInput.files[0]
    openedFile(file, null)
  }
})

async function openFile () {
  if (window.showOpenFilePicker != null) {
    const handles = await window.showOpenFilePicker({
      types: [{ description: 'map or log JSON file', accept: { 'application/json': ['.json'] } }]
    })
    if (handles.length >= 1) {
      const handle = handles[0]
      const file = await handle.getFile()
      openedFile(file, handle)
    }
  } else {
    fileInput.click()
  }
}

async function saveFile (text, extension) {
  const fullName = fileName + extension
  if (fileHandle == null) {
    if (window.showSaveFilePicker == null) {
      const a = document.createElement('a')
      a.download = fullName
      a.href = window.URL.createObjectURL(new window.Blob([text], {}))
      a.click()
      setTimeout(() => URL.revokeObjectURL(a.href), 40000)
      clearStateChanged()
      render()
      return
    }
    try {
      fileHandle = await window.showSaveFilePicker({ suggestedName: fullName })
    } catch (err) {
      // user canceled
      return
    }
  }
  const opts = { mode: 'readwrite' }
  if (
    await fileHandle.queryPermission(opts) !== 'granted' &&
    await fileHandle.requestPermission(opts) !== 'granted'
  ) {
    return
  }
  const writable = await fileHandle.createWritable()
  await writable.write(text)
  await writable.close()
  clearStateChanged()
  fileName = removeEnd(fileHandle.name, extension)
  render()
}

async function openedFile (file, handle) {
  openedFileText(await file.text(), file.name, handle)
}

function openedFileText (fileText, name, handle) {
  let data
  try {
    data = JSON.parse(fileText)
  } catch (err) {
    window.alert(`Error: File format is invalid.\n${err.message}`)
    return
  }
  let success = false
  try {
    const valid = loadFile(data, name, handle)
    success = true
    if (!valid) window.alert('Error: File format is invalid.')
  } finally {
    if (!success) {
      window.alert('Error while loading file.')
      game = null
    }
  }
}

function unloadEvent (event) {
  event.preventDefault()
  event.returnValue = 'Unsaved changes'
  return event.returnValue
}

function setStateChanged () {
  mapChanged()
  if (activeView === VIEW_PLAYBACK) return
  if (stateChanged) return
  stateChanged = true
  window.addEventListener('beforeunload', unloadEvent, { capture: true })
}

function clearStateChanged () {
  if (!stateChanged) return
  stateChanged = false
  window.removeEventListener('beforeunload', unloadEvent, { capture: true })
}

function onSave () {
  if (activeView === VIEW_EDITOR) {
    if (!isMapValid) return
    const mapRaw = {
      fileType: 'trees-map',
      start: map.start,
      nodes: map.nodes,
      edges: map.edges,
    }
    saveFile(JSON.stringify(mapRaw), MAP_EXTENSION)
  }
}

function loadFile (data, name, handle) {
  if (!isJSONObject(data)) {
    console.error("data is not json object")
    return false
  }
  if (data.fileType === 'trees-map') {
    const mapData = parseMap(data)
    if (mapData == null) {
      console.error("mapData is null")
      return false
    }
    if (stateChanged && !confirm(`Open file? Unsaved changes in "${fileName}" will be lost.`)) return true
    fileName = removeEnd(name, MAP_EXTENSION)
    fileHandle = handle
    activeView = VIEW_EDITOR
    initializeMap(mapData)
  } else if (data.fileType === 'trees-log') {
    const logData = parseLog(data)
    if (logData == null) {
      console.error("logData is null")
      return false
    }
    if (stateChanged && !confirm(`Open file? Unsaved changes in "${fileName}" will be lost.`)) return true
    fileName = removeEnd(name, LOG_EXTENSION)
    fileHandle = handle
    activeView = VIEW_PLAYBACK
    initializeMap(logData.map)
    game = logData
    playbackRound = 0
    playbackRoundFrame = 0
    playbackPlaying = false
  } else {
    console.error("invalid fileType")
    return false
  }

  render()
  return true
}

function parseMap (mapRaw) {
  if (!isJSONObject(mapRaw)) {
    console.error("map is not json object")
    return null
  }

  if (!Array.isArray(mapRaw.nodes)) {
    console.error("map nodes is not array")
    return null
  }
  if (!mapRaw.nodes.every(node => (
    isInteger(node.f) && node.f >= 0 && node.f <= MAX_F &&
    isInteger(node.a) && node.a >= 0 && node.a <= MAX_A &&
    typeof node.x === 'number' && typeof node.y === 'number'
  ))) {
    console.error("invalid map node")
    return null
  }
  const n = mapRaw.nodes.length

  if (!Array.isArray(mapRaw.start) || mapRaw.start.length !== 2){
    console.error("invalid start array")
    return null
  }
  if (!mapRaw.start.every(e => isInteger(e) && e >= 0 && e < n)) {
    console.error("invalid start nodes")
    return null
  }
  if (mapRaw.start[0] === mapRaw.start[1]) {
    console.error("players have same start position")
    return null
  }

  if (!Array.isArray(mapRaw.edges)) {
    console.error("map edges is not array")
    return null
  }
  if (!mapRaw.edges.every(edge => (
    isInteger(edge.b) && edge.b >= 0 && edge.b < n &&
    isInteger(edge.c) && edge.c >= 0 && edge.c < n &&
    edge.b !== edge.c
  ))) {
    console.error("invalid edges")
    return null
  }
  const adjList = getMapAdj(mapRaw)
  if (adjList == null) {
    console.error("adjList is null")
    return null
  }
  const mapData = {
    n,
    start: mapRaw.start,
    nodes: mapRaw.nodes,
    edges: mapRaw.edges,
    adj: adjList,
  }
  if (!isMapConnected(mapData)) {
    console.error("map is not connected")
    return null
  }
  return mapData
}

function parseLog (logData) {
  if (!isJSONObject(logData)) {
    console.error("logData is not json object")
    return null
  }
  const mapData = parseMap(logData.map)
  if (mapData == null) {
    console.error("mapData in parseLog is null")
    return null
  }
  if (!Array.isArray(logData.players) || logData.players.length !== 2) {
    console.error("invalid player information")
    return null
  }
  if (!logData.players.every(player =>
    isJSONObject(player) && typeof player.name === 'string')
  ) {
    console.error("invalid player name")
    return null
  }
  if (!Array.isArray(logData.rounds)) {
    console.error("logData rounds is not array")
    return null
  }
  if (!logData.rounds.every(round =>
    isJSONObject(round) &&
    Array.isArray(round.actions) &&
    round.actions.length === 2 &&
    round.actions.every(action =>
      Array.isArray(action) &&
      action.length === mapData.n &&
      action.every(val => isInteger(val) && val >= 0 && val <= 1e9)
    )
  )) {
    console.error("invalid logData rounds")
    return null
  }

  const roundStates = computeLogRounds(mapData, logData.rounds)
  if (roundStates == null) {
    console.error('invalid game action')
    return null
  }

  let totalPoints = 0
  let totalIncome = 0
  for (const node of mapData.nodes) {
    totalPoints += node.a
    totalIncome += node.f
  }

  let maxPlant = 1
  for (const state of roundStates) {
    if (state.actions == null) continue
    for (let i = 0; i < mapData.n; i++) {
      const action0 = state.actions[0][i]
      const action1 = state.actions[1][i]
      const plant0 = getPlanted(action0, state.distance[0][i])
      const plant1 = getPlanted(action1, state.distance[1][i])
      maxPlant = Math.max(maxPlant, plant0, plant1, action0 - plant0, action1 - plant1)
    }
  }
  const plantBarScale = Math.min(10, 80 / maxPlant)

  return {
    map: mapData,
    players: logData.players,
    states: roundStates,
    r: logData.rounds.length,
    totalPoints,
    totalIncome,
    plantBarScale,
  }
}

function isJSONObject (value) {
  return value != null && typeof value === 'object'
}

function isInteger (value) {
  return typeof value === 'number' && value === Math.floor(value)
}

// Map logic

function initializeMap (newMap) {
  map = newMap

  mapViewScale = 1
  mapViewX = 0
  mapViewY = 0

  if (map.n !== 0) {
    const OFFSET = 150
    let offsetTop = 0
    if (activeView === VIEW_PLAYBACK) {
      offsetTop = playbackPaddingTop()
    }

    const nodesX = map.nodes.map(n => n.x)
    const nodesY = map.nodes.map(n => n.y)
    const minX = nodesX.reduce((a, b) => Math.min(a, b))
    const maxX = nodesX.reduce((a, b) => Math.max(a, b))
    const minY = nodesY.reduce((a, b) => Math.min(a, b))
    const maxY = nodesY.reduce((a, b) => Math.max(a, b))
    const scale = Math.min(
      viewWidth / (maxX - minX + OFFSET),
      (viewHeight - offsetTop) / (maxY - minY + OFFSET)
    )
    mapViewScale = Math.min(1, Math.max(0.01, scale))
    mapViewX = viewWidth / 2 - (minX + maxX) / 2 * mapViewScale
    mapViewY = offsetTop/ 2 + viewHeight / 2 - (minY + maxY) / 2 * mapViewScale
  }

  if (playbackTimeout) {
    clearTimeout(playbackTimeout)
    playbackTimeout = 0
  }

  selectedNodes = new Set()
  dragStartPt = null
  dragEndPt = null
  addMode = false
  addPt = null
  addEdgeStartNode = -1
  addEdgeEndNode = -1
  addEdgeClick = false

  clearStateChanged()
  mapChanged()
}

function createNewMap () {
  initializeMap({
    n: 0,
    start: [-1, -1],
    nodes: [],
    edges: [],
    adj: [],
  })

  activeView = VIEW_EDITOR
  render()
}

function addNode (point) {
  map.nodes.push({
    a: 0,
    f: 0,
    x: point.x,
    y: point.y,
  })
  map.n = map.nodes.length
  map.adj.push(new Set())
  setStateChanged()
  return map.n - 1
}

function addEdge (p1, p2) {
  if (p1 === p2) return
  for (let i = 0; i < map.edges.length; i++) {
    const edge = map.edges[i]
    if ((edge.b === p1 && edge.c === p2) ||
        (edge.b === p2 && edge.c === p1)) {
      map.edges.splice(i, 1)
      map.adj = getMapAdj(map)
      setStateChanged()
      return
    }
  }
  map.edges.push({ b: p1, c: p2 })
  map.adj = getMapAdj(map)
  setStateChanged()
}

function deleteSelected () {
  const oldToNewNode = []
  const newNodes = []
  let nextId = 0
  for (let i = 0; i < map.n; i++) {
    if (selectedNodes.has(i)) {
      oldToNewNode[i] = -1
    } else {
      oldToNewNode[i] = nextId
      newNodes.push(map.nodes[i])
      nextId++
    }
  }
  const newEdges = []
  for (const edge of map.edges) {
    const newEdge = { b: oldToNewNode[edge.b], c: oldToNewNode[edge.c] }
    if (newEdge.b !== -1 && newEdge.c !== -1) newEdges.push(newEdge)
  }
  map.nodes = newNodes
  map.edges = newEdges
  map.start = map.start.map(i => i === -1 ? -1 : oldToNewNode[i])
  map.n = map.nodes.length
  map.adj = getMapAdj(map)
  selectedNodes = new Set()
  setStateChanged()
  render()
}

function setStartNode (player) {
  if (selectedNodes.size !== 1) return
  const node = [...selectedNodes][0]
  map.start[player] = node
  if (map.start[1 - player] === node) map.start[1 - player] = -1
  setStateChanged()
  render()
}

function setStart0 () { setStartNode(0) }
function setStart1 () { setStartNode(1) }

function setNodeA (event) {
  let value = event.target.value
  value = Math.max(0, Math.min(MAX_A, Math.round(+value)))
  for (const node of selectedNodes) {
    map.nodes[node].a = value
  }
  setStateChanged()
  render()
}

function setNodeF (event) {
  let value = event.target.value
  value = Math.max(0, Math.min(MAX_F, Math.round(+value)))
  for (const node of selectedNodes) {
    map.nodes[node].f = value
  }
  setStateChanged()
  render()
}

// Compute adjacency list.
// Returns null if there are duplicate edges.
function getMapAdj (mapData) {
  const adjList = []
  for (let i = 0; i < mapData.nodes.length; i++) adjList.push(new Set())
  for (const edge of mapData.edges) {
    if (adjList[edge.b].has(edge.c)) {
      console.error("duplicate edge")
      return null
    }
    adjList[edge.b].add(edge.c)
    adjList[edge.c].add(edge.b)
  }
  return adjList
}

// Check that the graph is connected
function isMapConnected (mapData) {
  const visited = repeat(mapData.n, false)
  const queue = [0]
  while (queue.length !== 0) {
    let i = queue.pop()
    if (!visited[i]) {
      visited[i] = true
      for (const o of mapData.adj[i]) queue.push(o)
    }
  }
  return visited.every(b => b)
}

function mapChanged () {
  isMapValid =
    map.start[0] !== -1 &&
    map.start[1] !== -1 &&
    isMapConnected(map)
}

// Game logic

function getPlanted (action, distance) {
  if (action === 0) return 0
  if (distance === 0) throw new Error('planted at owned node')
  return Math.floor(action / (distance * distance))
}

// Computes the next state from previous state and round data,
// or returns null if there is an invalid move
function computeGameStep (mapData, prevState, round) {
  const nextState = {
    stockpile: [
      prevState.stockpile[0] + prevState.income[0],
      prevState.stockpile[1] + prevState.income[1],
    ],
    distance: [],
    actions: round.actions,
    owner: prevState.owner.slice(),
    income: [0, 0],
    points: [0, 0],
  }

  // Compute distances to nearest owned node with BFS
  for (const player of [0, 1]) {
    let queue = []
    for (let i = 0; i < mapData.n; i++) {
      if (prevState.owner[i] === player) queue.push(i)
    }
    const dists = repeat(mapData.n, -1)
    let dist = 0
    while (queue.length !== 0) {
      const nextQueue = []
      for (const i of queue) {
        if (dists[i] !== -1) continue
        dists[i] = dist
        for (const o of mapData.adj[i]) {
          if (dists[o] === -1) nextQueue.push(o)
        }
      }
      dist++
      queue = nextQueue
    }
    nextState.distance.push(dists)
  }

  // Compute new owners
  for (let i = 0; i < mapData.n; i++) {
    if (nextState.owner[i] !== -1) {
      if (round.actions[0][i] !== 0 || round.actions[1][i] !== 0) {
        console.error("Can't plant on owned nodes")
        return null
      }
      continue
    }
    const plant0 = getPlanted(round.actions[0][i], nextState.distance[0][i])
    const plant1 = getPlanted(round.actions[1][i], nextState.distance[1][i])
    if (plant0 > plant1) nextState.owner[i] = 0
    if (plant1 > plant0) nextState.owner[i] = 1
  }

  // Compute new stockpile
  for (let i = 0; i < mapData.n; i++) {
    nextState.stockpile[0] -= round.actions[0][i]
    nextState.stockpile[1] -= round.actions[1][i]
  }
  if (nextState.stockpile[0] < 0 || nextState.stockpile[1] < 0) {
    console.error("Can't plant more than what you have")
    return null
  }

  // Compute new income and points
  for (let i = 0; i < mapData.n; i++) {
    if (nextState.owner[i] !== -1) {
      nextState.income[nextState.owner[i]] += mapData.nodes[i].f
      nextState.points[nextState.owner[i]] += mapData.nodes[i].a
    }
  }

  return nextState
}

function computeLogRounds (mapData, rounds) {
  let currentState = {
    stockpile: [0, 0],
    owner: repeat(mapData.n, -1),
    income: [
      mapData.nodes[mapData.start[0]].f,
      mapData.nodes[mapData.start[1]].f,
    ],
    points: [
      mapData.nodes[mapData.start[0]].a,
      mapData.nodes[mapData.start[1]].a,
    ],
  }
  currentState.owner[mapData.start[0]] = 0
  currentState.owner[mapData.start[1]] = 1

  const roundStates = []
  roundStates.push(currentState)
  for (const round of rounds) {
    currentState = computeGameStep(mapData, currentState, round)
    if (currentState === null) return null
    roundStates.push(currentState)
  }
  return roundStates
}

// Event handlers

function onInputFileName (event) {
  if (event.target.value !== fileName) {
    fileHandle = null
    fileName = event.target.value
    render()
  }
}

function cursorToMap (cursor) {
  return {
    x: Math.round((cursor.x - mapViewX) / mapViewScale),
    y: Math.round((cursor.y - mapViewY) / mapViewScale),
  }
}

function getTwoFingerParams () {
  const [a, b] = mapTouches.values()
  const dx = a.x - b.x
  const dy = a.y - b.y
  return {
    x: (a.x + b.x) / 2,
    y: (a.y + b.y) / 2,
    d: Math.sqrt(dx * dx + dy * dy),
  }
}

function zoomMap (anchor, factor) {
  const newScale = Math.max(0.01, Math.min(1, mapViewScale * (factor || 0)))
  mapViewX = anchor.x + (mapViewX - anchor.x) * newScale / mapViewScale
  mapViewY = anchor.y + (mapViewY - anchor.y) * newScale / mapViewScale
  mapViewScale = newScale
}

function startDrag (event) {
  event.currentTarget.setPointerCapture(event.pointerId)
  dragPt = eventSVGPoint(event)
}

function dragDelta (event) {
  const cursor = eventSVGPoint(event)
  const delta = { x: cursor.x - dragPt.x, y: cursor.y - dragPt.y }
  dragPt = cursor
  return delta
}

function onWheelMap (event) {
  event.preventDefault()
  if (event.ctrlKey) {
    mapViewX -= event.deltaX
    mapViewY -= event.deltaY
  } else {
    zoomMap(eventSVGPoint(event), Math.exp(-event.deltaY / 300))
  }
  render()
}

function onPointerdownMap (event) {
  event.preventDefault()
  if (document.activeElement != null) document.activeElement.blur()
  altEvent(event.altKey)

  const cursor = eventSVGPoint(event)

  // Two-finger touch
  if (event.pointerType === 'touch' && mapTouches.size < 2) {
    if (mapTouches.size === 1 && mapTouches.keys().next().value === primaryPointerId) {
      // Cancel the single pointer action of the first touch
      selectedNodes = oldSelectedNodes
      primaryPointerId = null
      if (dragPt != null) {
        dragStartPt = null
        dragEndPt = null
        nodeDrag = false
        dragPt = null
      }
      if (addEdgeStartNode !== -1) {
        addEdgeStartNode = -1
        addEdgeEndNode = -1
        addPt = null
      }
    }
    mapTouches.set(event.pointerId, cursor)
    event.currentTarget.setPointerCapture(event.pointerId)
    if (mapTouches.size === 2) {
      lastTwoFingerParams = getTwoFingerParams()
      render()
      return
    }
  }

  if (primaryPointerId == null) {
    primaryPointerId = event.pointerId
    oldSelectedNodes = selectedNodes

    // Find the node under the cursor, -1 if none
    let n = event.target
    let id = -1
    while (n != null && n.tagName !== 'SVG') {
      if (n.classList && n.classList.contains('node')) {
        id = n.dataNodeId
        break
      }
      n = n.parentNode
    }

    if (event.button === 1) {
      // middle mouse button panning
      startDrag(event)
    }
    if (event.button === 0) {
      if (id !== -1) {
        if (addMode) {
          if (addEdgeStartNode !== -1) {
            addEdge(addEdgeStartNode, id)
          }
          addEdgeClick = addEdgeStartNode === -1
          addEdgeStartNode = id
          addEdgeEndNode = -1
          addPt = null
          event.currentTarget.setPointerCapture(event.pointerId)
        } else if (event.ctrlKey) {
          if (selectedNodes.has(id)) selectedNodes.delete(id)
          else selectedNodes.add(id)
        } else {
          if (!selectedNodes.has(id)) {
            selectedNodes = new Set()
            selectedNodes.add(id)
          }
          dragStartPt = dragEndPt = cursorToMap(cursor)
          startDrag(event)
          nodeDrag = true
        }
      } else if (addMode) {
        const newNode = addNode(cursorToMap(cursor))
        if (addEdgeStartNode !== -1) addEdge(addEdgeStartNode, newNode)
        addEdgeStartNode = newNode
        addEdgeEndNode = -1
        addPt = null
        addEdgeClick = false
        event.currentTarget.setPointerCapture(event.pointerId)
      } else {
        if (!event.ctrlKey) selectedNodes = new Set()
        dragStartPt = dragEndPt = cursorToMap(cursor)
        startDrag(event)
      }
      render()
    }
  }
}

function updateAddPt (event) {
  const pt = cursorToMap(eventSVGPoint(event))
  let overNode = map.n - 1
  const nodeDist = 48 * 48
  for (; overNode >= 0; overNode--) {
    const dx = pt.x - map.nodes[overNode].x
    const dy = pt.y - map.nodes[overNode].y
    if (dx * dx + dy * dy <= nodeDist) break
  }
  addPt = null
  addEdgeEndNode = -1
  if (overNode === -1) {
    addPt = pt
  } else if (addEdgeStartNode !== -1 && addEdgeStartNode !== overNode) {
    addEdgeEndNode = overNode
  }
}

function onPointermoveMap (event) {
  if (mapTouches.has(event.pointerId)) {
    mapTouches.set(event.pointerId, eventSVGPoint(event))
    if (mapTouches.size === 2) {
      const params = getTwoFingerParams()

      mapViewX += params.x - lastTwoFingerParams.x
      mapViewY += params.y - lastTwoFingerParams.y
      zoomMap(params, params.d / lastTwoFingerParams.d)

      lastTwoFingerParams = params
      render()
    }
  }
  if (event.pointerId === primaryPointerId) {
    if (dragPt != null) {
      if (dragStartPt != null) {
        dragEndPt = cursorToMap(eventSVGPoint(event))
      } else {
        const delta = dragDelta(event)
        mapViewX += delta.x
        mapViewY += delta.y
      }
      render()
    }
  }
  if (addMode && (event.pointerId === primaryPointerId || event.buttons === 0)) {
    updateAddPt(event)
    render()
  }
}

function onPointerupMap (event) {
  if (mapTouches.has(event.pointerId)) {
    mapTouches.delete(event.pointerId)
    event.currentTarget.releasePointerCapture(event.pointerId)
  }
  if (event.pointerId === primaryPointerId) {
    primaryPointerId = null
    if (dragPt != null) {
      if (dragStartPt != null) {
        dragEndPt = cursorToMap(eventSVGPoint(event))
        if (nodeDrag) {
          const deltaX = dragEndPt.x - dragStartPt.x
          const deltaY = dragEndPt.y - dragStartPt.y
          for (const i of selectedNodes) {
            map.nodes[i].x += deltaX
            map.nodes[i].y += deltaY
          }
          setStateChanged()
        } else {
          const xMin = Math.min(dragStartPt.x, dragEndPt.x)
          const xMax = Math.max(dragStartPt.x, dragEndPt.x)
          const yMin = Math.min(dragStartPt.y, dragEndPt.y)
          const yMax = Math.max(dragStartPt.y, dragEndPt.y)
          for (let i = 0; i < map.n; i++) {
            const node = map.nodes[i]
            if (xMin <= node.x && node.x < xMax && yMin <= node.y && node.y < yMax) {
              if (selectedNodes.has(i)) selectedNodes.delete(i)
              else selectedNodes.add(i)
            }
          }
        }

        nodeDrag = false
        dragStartPt = null
        dragEndPt = null
        render()
      }
      dragPt = null
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
    if (addEdgeStartNode !== -1) {
      updateAddPt(event)

      let endNode = -1
      if (addPt != null) {
        endNode = addNode(addPt)
      } else if (addEdgeEndNode !== -1) {
        endNode = addEdgeEndNode
      }
      if (endNode !== -1) {
        addEdge(addEdgeStartNode, endNode)
        addEdgeStartNode = -1
        addEdgeEndNode = -1
        addPt = null
      }
      if (!addEdgeClick) {
        addEdgeStartNode = -1
        addEdgeEndNode = -1
        addPt = null
      }
      event.currentTarget.releasePointerCapture(event.pointerId)
      render()
    }
  }
}

function onPointerleaveMap (event) {
  if (addPt != null || addEdgeEndNode !== -1) {
    addPt = null
    addEdgeEndNode = -1
    render()
  }
}

function toggleAddMode () {
  addPt = null
  addEdgeStartNode = -1
  addEdgeEndNode = -1
  addMode = !addMode
  render()
}

window.addEventListener('resize', render)

// Playback

function isAtEnd () {
  return playbackRound === game.r
}

const PLAY_SPEED = 500
function playTick () {
  playbackTimeout = 0
  playbackForward()
}

function updatePlayback () {
  if (isAtEnd()) {
    playbackPlaying = false
  }
  render()
  if (playbackTimeout !== 0 && !playbackPlaying) {
    clearTimeout(playbackTimeout)
    playbackTimeout = 0
  } else if (playbackTimeout === 0 && playbackPlaying) {
    playbackTimeout = setTimeout(playTick, PLAY_SPEED)
  }
}

function playPause () {
  if (activeView !== VIEW_PLAYBACK) return
  playbackPlaying = !playbackPlaying
  if (playbackPlaying && isAtEnd()) {
    playbackRound = 0
    playbackRoundFrame = 0
  }
  updatePlayback()
}

function playbackBackward () {
  if (activeView !== VIEW_PLAYBACK) return
  if (!(playbackRound === 0 && playbackRoundFrame === 0)) {
    playbackRoundFrame--
    if (playbackRoundFrame === -1) {
      playbackRoundFrame = FRAMES_PER_ROUND - 1
      playbackRound--
    }
    updatePlayback()
  }
}

function playbackForward () {
  if (activeView !== VIEW_PLAYBACK) return
  if (!isAtEnd()) {
    playbackRoundFrame++
    if (playbackRoundFrame === FRAMES_PER_ROUND) {
      playbackRoundFrame = 0
      playbackRound++
    }
    updatePlayback()
  }
}

function playbackRoundBackward () {
  if (activeView !== VIEW_PLAYBACK) return
  if (!(playbackRound === 0 && playbackRoundFrame === 0)) {
    if (playbackRoundFrame === 0) {
      playbackRound--
    } else {
      playbackRoundFrame = 0
    }
    updatePlayback()
  }
}

function playbackRoundForward () {
  if (activeView !== VIEW_PLAYBACK) return
  if (!isAtEnd()) {
    playbackRound++
    playbackRoundFrame = 0
    updatePlayback()
  }
}

function playbackStart () {
  if (activeView !== VIEW_PLAYBACK) return
  playbackRound = 0
  playbackRoundFrame = 0
  updatePlayback()
}

function playbackEnd () {
  if (activeView !== VIEW_PLAYBACK) return
  playbackRound = game.r
  playbackRoundFrame = 0
  updatePlayback()
}

// Keyboard

function altEvent (altKey) {
  if (altPressed !== altKey) {
    altPressed = altKey
    if (activeView === VIEW_EDITOR) toggleAddMode()
  }
}

document.addEventListener('keydown', event => {
  if (event.ctrlKey && event.code === 'KeyS') {
    event.preventDefault()
    onSave()
  }
  altEvent(event.altKey)
  if (event.target.tagName === 'INPUT') return

  if (event.code === 'Space' && !event.repeat) playPause()
  if (event.code === 'ArrowLeft') playbackBackward()
  if (event.code === 'ArrowRight') playbackForward()
  if (event.code === 'PageUp') playbackRoundBackward()
  if (event.code === 'PageDown') playbackRoundForward()
  if (event.code === 'Home') playbackStart()
  if (event.code === 'End') playbackEnd()

  if (event.code === 'Delete' && activeView === VIEW_EDITOR) {
    deleteSelected()
  }
})

document.addEventListener('keyup', event => {
  altEvent(event.altKey)
})

window.addEventListener('blur', () => {
  // When the user does Alt+Tab, we don't receive the Alt key-up event.
  altEvent(false)
})

// Rendering

const GAUGE_R = 48
const GAUGE_LEN = Math.PI * 0.7
const GAUGE_START = (Math.PI - GAUGE_LEN) / 2
const GAUGE_END = GAUGE_START + GAUGE_LEN
const GAUGE_BG_D =
  `M ${GAUGE_R * Math.sin(GAUGE_START)} ${GAUGE_R * Math.cos(GAUGE_START)} ` +
  `A ${GAUGE_R} ${GAUGE_R} 0 0 0 ${GAUGE_R * Math.sin(GAUGE_END)} ${GAUGE_R * Math.cos(GAUGE_END)} ` +
  `L ${GAUGE_R * Math.sin(-GAUGE_START)} ${GAUGE_R * Math.cos(-GAUGE_START)} ` +
  `A ${GAUGE_R} ${GAUGE_R} 0 0 1 ${GAUGE_R * Math.sin(-GAUGE_END)} ${GAUGE_R * Math.cos(-GAUGE_END)} ` +
  `Z`

function nodePos (i) {
  const p = map.nodes[i]
  if (nodeDrag && selectedNodes.has(i)) {
    return `${p.x + dragEndPt.x - dragStartPt.x} ${p.y + dragEndPt.y - dragStartPt.y}`
  }
  return `${p.x} ${p.y}`
}

function renderMap () {
  const mapChildren = []
  for (let i = 0; i < map.edges.length; i++) {
    const edge = map.edges[i]
    mapChildren.push(h('path.edge', { attrs: { d: `M ${nodePos(edge.b)} L ${nodePos(edge.c)}` } }))
  }
  if (addEdgeStartNode !== -1) {
    const p1 = map.nodes[addEdgeStartNode]
    let p2 = addPt
    if (addEdgeEndNode != -1) {
      p2 = map.nodes[addEdgeEndNode]
    }
    if (p2 != null) {
      mapChildren.push(h('path.edge.addpreview', { attrs: { d: `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}` } }))
    }
  }
  for (let i = 0; i < map.nodes.length; i++) {
    const node = map.nodes[i]
    let owner = -1
    if (activeView === VIEW_PLAYBACK) {
      owner = game.states[playbackRound].owner[i]
    } else {
      if (map.start[0] === i) owner = 0
      if (map.start[1] === i) owner = 1
    }
    const circleFill =
      owner === 0 ? 'fill-p0' :
      owner === 1 ? 'fill-p1' :
      'fill-gray-300'
    const aEnd = GAUGE_START + GAUGE_LEN * node.a / MAX_A
    const fEnd = GAUGE_START + GAUGE_LEN * node.f / MAX_F

    const nodeChildren = [
      h('path.fill-gray-200', { attrs: { d: GAUGE_BG_D } }),
      h('path.fill-a', { attrs: { d: `M 0 0 L ${GAUGE_R * Math.sin(-GAUGE_START)} ${GAUGE_R * Math.cos(-GAUGE_START)} ` +
        `A ${GAUGE_R} ${GAUGE_R} 0 0 1 ${GAUGE_R * Math.sin(-aEnd)} ${GAUGE_R * Math.cos(-aEnd)} Z` } }),
        h('path.fill-f', { attrs: { d: `M 0 0 L ${GAUGE_R * Math.sin(GAUGE_START)} ${GAUGE_R * Math.cos(GAUGE_START)} ` +
          `A ${GAUGE_R} ${GAUGE_R} 0 0 0 ${GAUGE_R * Math.sin(fEnd)} ${GAUGE_R * Math.cos(fEnd)} Z` } }),
      h('circle', { class: { [circleFill]: true }, attrs: { r: 40 } }),
      h('circle.focus', { attrs: { r: 52 } }),
      h('title', `Attractivity: ${node.a}\nFertility: ${node.f}`),
    ]

    if (activeView === VIEW_PLAYBACK && playbackRoundFrame >= 2) {
      const NUM_Y = 10
      const NUM_X = 14

      const state = game.states[playbackRound + 1]
      const action0 = state.actions[0][i]
      const action1 = state.actions[1][i]
      const plant0 = getPlanted(action0, state.distance[0][i])
      const plant1 = getPlanted(action1, state.distance[1][i])
      const barScale = game.plantBarScale
      if (playbackRoundFrame >= 2 && action0 !== 0) {
        const opacity = playbackRoundFrame < 4 || plant0 > plant1 ? 1 : 0.5
        nodeChildren.push(h('text.text-anchor-end.font-size-20.fill-p0',
          { attrs: { opacity, x: -NUM_X, y: -NUM_Y } },
          plant0))
        nodeChildren.push(h('rect.fill-p0',
          { attrs: { opacity, x: -9, y: -plant0 * barScale, width: 5, height: plant0 * barScale } }))
        if (action0 !== plant0) {
          nodeChildren.push(h('text.text-anchor-end.font-size-20.fill-p0-600',
            { attrs: { opacity, x: -NUM_X, y: NUM_Y } },
            action0 - plant0))
          nodeChildren.push(h('rect.fill-p0-600',
            { attrs: { opacity, x: -9, y: 0, width: 5, height: (action0 - plant0) * barScale } }))
        }
      }
      if (playbackRoundFrame >= 3 && action1 !== 0) {
        const opacity = playbackRoundFrame < 4 || plant1 > plant0 ? 1 : 0.5
        nodeChildren.push(h('text.text-anchor-start.font-size-20.fill-p1',
          { attrs: { opacity, x: NUM_X, y: -NUM_Y } },
          plant1))
        nodeChildren.push(h('rect.fill-p1',
          { attrs: { opacity, x: 4, y: -plant1 * barScale, width: 5, height: plant1 * barScale } }))
        if (action1 !== plant1) {
          nodeChildren.push(h('text.text-anchor-start.font-size-20.fill-p1-600',
            { attrs: { opacity, x: NUM_X, y: NUM_Y } },
            action1 - plant1))
          nodeChildren.push(h('rect.fill-p1-600',
            { attrs: { opacity, x: 4, y: 0, width: 5, height: (action1 - plant1) * barScale } }))
        }
      }
    }

    const selected = selectedNodes.has(i)
    let posX = node.x
    let posY = node.y
    if (nodeDrag && selected) {
      posX += dragEndPt.x - dragStartPt.x
      posY += dragEndPt.y - dragStartPt.y
    }

    mapChildren.push(h('g.node',
      {
        class: { selected },
        attrs: { transform: `translate(${posX}, ${posY})` },
        props: { dataNodeId: i },
      },
      nodeChildren
    ))
  }

  if (addPt != null) {
    mapChildren.push(h('g.node.addpreview',
      {
        attrs: { transform: `translate(${addPt.x}, ${addPt.y})` },
      },
      [
        h('path.fill-gray-200', { attrs: { d: GAUGE_BG_D } }),
        h('circle.fill-gray-300', { attrs: { r: 40 } }),
      ]
    ))
  }

  if (!nodeDrag && dragStartPt != null) {
    const xMin = Math.min(dragStartPt.x, dragEndPt.x) - 0.5
    const xMax = Math.max(dragStartPt.x, dragEndPt.x) + 0.5
    const yMin = Math.min(dragStartPt.y, dragEndPt.y) - 0.5
    const yMax = Math.max(dragStartPt.y, dragEndPt.y) + 0.5

    mapChildren.push(h('rect.fill-none.stroke-dashed',
      { attrs: { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin } }))
  }

  return h('g', { attrs: { transform: `translate(${mapViewX}, ${mapViewY}) scale(${mapViewScale})` } }, mapChildren)
}

function renderMapSvg (children) {
  return h('svg', {
    attrs: { viewBox: `0 0 ${viewWidth} ${viewHeight}` },
    on: {
      wheel: onWheelMap,
      pointerdown: onPointerdownMap,
      pointermove: onPointermoveMap,
      pointerup: onPointerupMap,
      pointercancel: onPointerupMap,
      pointerleave: onPointerleaveMap,
    }
  }, children)
}

function renderEditor () {
  let msg = ''
  if (!isMapValid) {
    if (map.start[0] !== -1 && map.start[1] !== -1) {
      msg = 'the graph is not connected'
    } else {
      msg = 'one or both start nodes are not set'
    }
    msg = `Cannot save, because ${msg}.`
  } else {
    msg = `${map.n} nodes, ${map.edges.length} edges`
  }

  let selectedSidebar = null
  if (selectedNodes.size !== 0) {
    const node = [...selectedNodes][0]
    const label = selectedNodes.size === 1 ? 'selected node' : `${selectedNodes.size} selected nodes`
    const sidebarChildren = [
      label,
      h('button.default',
        { attrs: { title: `Delete ${label} (Delete)` }, on: { click: deleteSelected } },
        'delete'),
      'attractivity',
      h('input', { attrs: { type: 'number', min: 0, max: MAX_A, step: 1 }, props: { value: map.nodes[node].a }, on: { input: setNodeA } }),
      h('input', { attrs: { type: 'range', min: 0, max: MAX_A, step: 1 }, props: { value: map.nodes[node].a }, on: { input: setNodeA } }),
      'fertility',
      h('input', { attrs: { type: 'number', min: 0, max: MAX_F, step: 1 }, props: { value: map.nodes[node].f }, on: { input: setNodeF } }),
      h('input', { attrs: { type: 'range', min: 0, max: MAX_F, step: 1 }, props: { value: map.nodes[node].f }, on: { input: setNodeF } }),
    ]
    if (selectedNodes.size === 1) {
      sidebarChildren.push(
        'set as start node',
        h('div.start-selector', [
          h('button.border-p0', { class: { 'bg-p0': node === map.start[0], 'secondary': node !== map.start[0] }, on: { click: setStart0 } }),
          h('button.border-p1', { class: { 'bg-p1': node === map.start[1], 'secondary': node !== map.start[1] }, on: { click: setStart1 } }),
        ])
      )
    }
    selectedSidebar = h('div.editor-selected-sidebar', sidebarChildren)
  }

  return h('div.h-full', [
    h('div.editor-ui', [
      h('div.editor-toolbar', [
        h('span.font-bold', 'map editor'),
        h('input', { props: { value: fileName }, on: { input: onInputFileName } }),
        h('button.default',
          {
            class: { error: !isMapValid },
            attrs: { disabled: !isMapValid || !stateChanged, title: 'Save to file (Ctrl+S)' },
            on: { click: onSave }
          },
          'save'),
        h('button.default',
          {
            class: { secondary: !addMode },
            attrs: { title: 'Add nodes and edges (Alt)' },
            on: { click: toggleAddMode }
          },
          '+'),
        msg
      ]),
      selectedSidebar,
    ]),
    renderMapSvg(renderMap())
  ])
}

function playbackPaddingTop () {
  let offsetTop = 170
  if (viewWidth < 1200) offsetTop += 40
  return offsetTop
}

const FRAME_STR = [
  '○○○○',
  '●○○○',
  '●●○○',
  '●●●○',
  '●●●●',
]
function renderPlayback () {
  const displayRound = Math.min(playbackRound + 1, game.r)
  let displayFrame = FRAME_STR[playbackRoundFrame]
  if (playbackRound === game.r) displayFrame = 'end'

  let firstTop = 20
  let barX = 250
  let frameIndPos = { x: 20, y: 60 }
  if (viewWidth < 1200) {
    frameIndPos = { x: 200, y: 20 }
    firstTop = 60
    barX = 20
  }
  const pointsBarTop = firstTop + 20
  const incomeTop = pointsBarTop + 50
  const incomeBarTop = incomeTop + 20
  const stockpileTop = incomeBarTop + 40
  const NRS_X = 100
  const centerX = viewWidth / 2
  const barWidth = viewWidth - 2 * barX

  const points = game.states[playbackRound + (playbackRoundFrame >= 4 ? 1 : 0)].points
  const income = game.states[playbackRound + (playbackRoundFrame >= 4 ? 1 : 0)].income
  let stockpile = game.states[playbackRound].stockpile.slice()
  if (playbackRoundFrame >= 1) {
    stockpile[0] += game.states[playbackRound].income[0]
    stockpile[1] += game.states[playbackRound].income[1]
  }
  if (playbackRoundFrame >= 2) {
    stockpile[0] = game.states[playbackRound + 1].stockpile[0]
  }
  if (playbackRoundFrame >= 3) {
    stockpile[1] = game.states[playbackRound + 1].stockpile[1]
  }

  const children = [
    renderMap(),
    h('rect.map-overlay',
      { attrs: { height: playbackPaddingTop(), width: viewWidth } }
    ),
    // current round
    h('text.text-anchor-start.font-size-30',
      { attrs: { x: 20, y: 20 } },
      `round ${displayRound}`
    ),
    // frame indicator
    h('text.text-anchor-start.font-size-20.fill-gray-400',
      { attrs: frameIndPos },
      displayFrame
    ),
    // player names
    h('text.text-anchor-start.font-size-30.fill-p0',
      { attrs: { x: barX, y: firstTop } },
      game.players[0].name
    ),
    h('text.text-anchor-end.font-size-30.fill-p1',
      { attrs: { x: viewWidth - barX, y: firstTop } },
      game.players[1].name
    ),
    // points
    h('text.font-size-30',
      { attrs: { x: centerX, y: firstTop } },
      'points'
    ),
    h('text.text-anchor-end.font-size-30.fill-p0',
      { attrs: { x: centerX - NRS_X, y: firstTop } },
      points[0]
    ),
    h('text.text-anchor-start.font-size-30.fill-p1',
      { attrs: { x: centerX + NRS_X, y: firstTop } },
      points[1]
    ),
    // points bar
    h('rect.fill-gray-200',
      { attrs: { x: barX, y: pointsBarTop, width: barWidth, height: 20 } }
    ),
    h('rect.fill-p0',
      { attrs: { x: barX, y: pointsBarTop, width: barWidth * points[0] / game.totalPoints, height: 20 } }
    ),
    h('rect.fill-p1',
      { attrs: {
        x: barX + barWidth * (1 - points[1] / game.totalPoints),
        y: pointsBarTop,
        width: barWidth * points[1] / game.totalPoints,
        height: 20
      } }
    ),
    h('rect.fill-black',
      { attrs: { x: centerX - 1, y: pointsBarTop, width: 2, height: 20 } }
    ),
    // income
    h('text.font-size-30',
      { attrs: { x: centerX, y: incomeTop } },
      'income'
    ),
    h('text.text-anchor-end.font-size-30.fill-p0',
      { attrs: { x: centerX - NRS_X, y: incomeTop } },
      income[0]
    ),
    h('text.text-anchor-start.font-size-30.fill-p1',
      { attrs: { x: centerX + NRS_X, y: incomeTop } },
      income[1]
    ),
    // income bar
    h('rect.fill-gray-200',
      { attrs: { x: barX, y: incomeBarTop, width: barWidth, height: 10 } }
    ),
    h('rect.fill-p0',
      { attrs: { x: barX, y: incomeBarTop, width: barWidth * income[0] / game.totalIncome, height: 10 } }
    ),
    h('rect.fill-p1',
      { attrs: {
        x: barX + barWidth * (1 - income[1] / game.totalIncome),
        y: incomeBarTop,
        width: barWidth * income[1] / game.totalIncome,
        height: 10
      } }
    ),
    h('rect.fill-black',
      { attrs: { x: centerX - 1, y: incomeBarTop, width: 2, height: 10 } }
    ),
    // stockpile
    h('text.font-size-30',
      { attrs: { x: centerX, y: stockpileTop } },
      'stockpile'
    ),
    h('text.text-anchor-end.font-size-30.fill-p0',
      { attrs: { x: centerX - NRS_X, y: stockpileTop } },
      stockpile[0]
    ),
    h('text.text-anchor-start.font-size-30.fill-p1',
      { attrs: { x: centerX + NRS_X, y: stockpileTop } },
      stockpile[1]
    ),
  ]

  const nav = h('div.playback-nav', [
    h('button.nav-button', { on: { click: playbackStart }, attrs: { title: 'Go to start (Home)' } }, h('svg', h('path', { attrs: { d: 'M18 18l-8.5-6L18 6v12zM8 6v12h-2V6h2z' } }))),
    h('button.nav-button', { on: { click: playbackRoundBackward }, attrs: { title: 'Previous round (Page Up)' } }, h('svg', h('path', { attrs: { d: 'M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z' } }))),
    h('button.nav-button', { on: { click: playbackBackward }, attrs: { title: 'Previous frame (Left Arrow)' } }, h('svg', h('path', { attrs: { d: 'M15.5 18V6l-8.5 6 8.5 6z' } }))),
    h('button.nav-button', { on: { click: playbackForward }, attrs: { title: 'Next frame (Right Arrow)' } }, h('svg', h('path', { attrs: { d: 'M8.5 18l8.5-6L8.5 6v12z' } }))),
    h('button.nav-button', { on: { click: playbackRoundForward }, attrs: { title: 'Next round (Page Down)' } }, h('svg', h('path', { attrs: { d: 'M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z' } }))),
    h('button.nav-button', { on: { click: playbackEnd }, attrs: { title: 'Go to end (End)' } }, h('svg', h('path', { attrs: { d: 'M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z' } }))),
  ])

  return h('div.h-full', [
    renderMapSvg(children),
    nav,
  ])
}

let vnode = document.getElementById('main')

function render () {
  const MIN_W = 750
  const MIN_H = 500
  viewWidth = Math.max(window.innerWidth, MIN_W, Math.floor(window.innerWidth * MIN_H / window.innerHeight))
  viewHeight = Math.max(window.innerHeight, MIN_H, Math.floor(window.innerHeight * MIN_W / window.innerWidth))

  const children = []
  if (activeView === VIEW_WELCOME) {
    if (hasFileUrl) {
      if (urlFetchError != null) {
        children.push(h('div.welcome', h('div', [
          h('h1', 'Failed to load file'),
          h('p', urlFetchError),
        ])))
      }
    } else {
      children.push(h('div.welcome', h('div', [
        h('h1', 'SOI 2023 round 1 – trees'),
        h('p', 'Open a map or log file by dropping it here or'),
        h('p', h('button.default', { on: { click: openFile }}, 'open a map or log file')),
        h('p', h('button.default', { on: { click: createNewMap }}, 'create a new map')),
      ])))
    }
  } else if (activeView === VIEW_EDITOR) {
    children.push(renderEditor())
  } else if (activeView === VIEW_PLAYBACK) {
    children.push(renderPlayback())
  }

  if (draggingOver) {
    children.push(h('div.drag-over', h('div', [
      h('h1', 'Drop a map or log file to open it'),
    ])))
  }

  const newVnode = h('div.h-full', children)
  vnode = patch(vnode, newVnode)

  let title = 'trees visualization'
  if (activeView === VIEW_EDITOR || activeView === VIEW_PLAYBACK) {
    if (fileName) title = fileName + ' - ' + title
    if (stateChanged) title = '*' + title
  }
  if (document.title !== title) document.title = title
}

render()
