libpov_2025/biome-ui.js
2023-10-21 01:55:49 +02:00

638 lines
17 KiB
JavaScript

"use strict";
const MIN_Y_DEFAULT = -31000
const MAX_Y_DEFAULT = 31000
const LIMIT_MIN = -37.5
const LIMIT_MAX = 137.5
const DRAW_MIN = LIMIT_MIN - 10
const DRAW_MAX = LIMIT_MAX + 10
const GRID_STEP = 10
let lastBiomeID = 0;
let biomePoints = [];
function addBiome(biomeDef) {
biomeDef.id = lastBiomeID;
biomePoints.push(biomeDef);
lastBiomeID++;
}
// Add a default biome at 50, 50
addBiome({name: generateBiomeName(50, 50), heat:50, humidity:50, min_y: MIN_Y_DEFAULT, max_y: MAX_Y_DEFAULT})
function getViewY() {
if (!inputViewY) {
return 0;
}
return inputViewY.value;
}
// FOREST-16 palette, plus black
const pointColor = "#913636";
const pointColorSelected = "#e19696";
const edgeColor = "#0f2c2e";
const gridColor = "#00000040";
const axisColor = "#000000";
const clearColor = "#ecddba";
const cellColors = [
"#64988e",
"#3d7085",
"#345644",
"#6b7f5c",
"#b0b17c",
"#e1c584",
"#c89660",
"#ad5f52",
"#692f11",
"#89542f",
"#796e63",
"#a17d5e",
"#b4a18f",
"#ecddba",
];
// returns the biome point by its given ID
// or null if it couldn't be found
function getBiomeByID(id) {
for(let b=0; b<biomePoints.length; b++) {
let biome = biomePoints[b];
if (biome.id === id) {
return biome;
}
}
return null;
}
// Returns a biome name for displaying it, based on heat and humidity values.
function generateBiomeName(heat, humidity) {
return "("+heat+","+humidity+")";
}
function biomePointToVoronoiPoint(point) {
let newPoint = { x: point.heat, y: point.humidity }
return newPoint;
}
function voronoiPointToBiomePoint(point) {
let newPoint = { heat: point.x, humidity: point.y }
return newPoint;
}
function putPoint(context, point) {
const ARROW_SIZE_SIDE = 2.25;
const ARROW_SIZE_CORNER = 2.5;
let x = point.heat
let y = point.humidity
// Point is out of bounds: Draw an arrow at the border
if (x < LIMIT_MIN || x > LIMIT_MAX || y < LIMIT_MIN || y > LIMIT_MAX) {
context.beginPath();
// top left corner
if (x < LIMIT_MIN && y < LIMIT_MIN) {
context.moveTo(LIMIT_MIN, LIMIT_MIN);
context.lineTo(LIMIT_MIN + ARROW_SIZE_CORNER, LIMIT_MIN);
context.lineTo(LIMIT_MIN, LIMIT_MIN + ARROW_SIZE_CORNER);
context.closePath();
context.fill();
// top right corner
} else if (x > LIMIT_MAX && y < LIMIT_MIN) {
context.moveTo(LIMIT_MAX, LIMIT_MIN);
context.lineTo(LIMIT_MAX - ARROW_SIZE_CORNER, LIMIT_MIN);
context.lineTo(LIMIT_MAX, LIMIT_MIN + ARROW_SIZE_CORNER);
context.closePath();
context.fill();
// bottom left corner
} else if (x < LIMIT_MIN && y > LIMIT_MAX) {
context.moveTo(LIMIT_MIN, LIMIT_MAX);
context.lineTo(LIMIT_MIN + ARROW_SIZE_CORNER, LIMIT_MAX);
context.lineTo(LIMIT_MIN, LIMIT_MAX - ARROW_SIZE_CORNER);
context.closePath();
context.fill();
// top right corner
} else if (x > LIMIT_MAX && y > LIMIT_MAX) {
context.moveTo(LIMIT_MAX, LIMIT_MAX);
context.lineTo(LIMIT_MAX - ARROW_SIZE_CORNER, LIMIT_MAX);
context.lineTo(LIMIT_MAX, LIMIT_MAX - ARROW_SIZE_CORNER);
context.closePath();
context.fill();
// left side
} else if (x < LIMIT_MIN) {
context.moveTo(LIMIT_MIN, y);
context.lineTo(LIMIT_MIN + ARROW_SIZE_SIDE, y + ARROW_SIZE_SIDE);
context.lineTo(LIMIT_MIN + ARROW_SIZE_SIDE, y - ARROW_SIZE_SIDE);
context.closePath();
context.fill();
// right side
} else if (x > LIMIT_MAX) {
context.moveTo(LIMIT_MAX, y);
context.lineTo(LIMIT_MAX - ARROW_SIZE_SIDE, y + ARROW_SIZE_SIDE);
context.lineTo(LIMIT_MAX - ARROW_SIZE_SIDE, y - ARROW_SIZE_SIDE);
context.closePath();
context.fill();
// top side
} else if (y < LIMIT_MIN) {
context.moveTo(x, LIMIT_MIN);
context.lineTo(x - ARROW_SIZE_SIDE, LIMIT_MIN + ARROW_SIZE_SIDE);
context.lineTo(x + ARROW_SIZE_SIDE, LIMIT_MIN + ARROW_SIZE_SIDE);
context.closePath();
context.fill();
// bottom side
} else if (y > LIMIT_MAX) {
context.moveTo(x, LIMIT_MAX);
context.lineTo(x - ARROW_SIZE_SIDE, LIMIT_MAX - ARROW_SIZE_SIDE);
context.lineTo(x + ARROW_SIZE_SIDE, LIMIT_MAX - ARROW_SIZE_SIDE);
context.closePath();
context.fill();
}
// Point is in bounds: Draw a dot
} else {
context.beginPath();
context.moveTo(0, 0);
context.arc(x, y, 2, 0, Math.PI * 2);
context.closePath();
context.fill();
}
};
function putGrid(context) {
context.lineWidth = 0.5;
context.strokeStyle = gridColor;
for (let x=0; x<=LIMIT_MAX; x+=GRID_STEP) {
context.beginPath();
context.moveTo(x, LIMIT_MIN);
context.lineTo(x, LIMIT_MAX);
context.stroke();
}
for (let x=-GRID_STEP; x>=LIMIT_MIN; x-=GRID_STEP) {
context.beginPath();
context.moveTo(x, LIMIT_MIN);
context.lineTo(x, LIMIT_MAX);
context.stroke();
}
for (let y=0; y<=LIMIT_MAX; y+=GRID_STEP) {
context.beginPath();
context.moveTo(LIMIT_MIN, y);
context.lineTo(LIMIT_MAX, y);
context.stroke();
}
for (let y=-GRID_STEP; y>=LIMIT_MIN; y-=GRID_STEP) {
context.beginPath();
context.moveTo(LIMIT_MIN, y);
context.lineTo(LIMIT_MAX, y);
context.stroke();
}
}
function putAxes(context) {
context.lineWidth = 0.75;
// heat axis (horizontal)
context.beginPath();
context.moveTo(LIMIT_MIN,0)
context.lineTo(LIMIT_MAX,0)
context.closePath();
context.stroke();
// humidity axis ()
context.beginPath();
context.moveTo(0,LIMIT_MIN)
context.lineTo(0,LIMIT_MAX)
context.closePath();
context.stroke();
}
// Cache diagram object for performance boost
let cachedVoronoiDiagram = null;
function getVoronoiDiagram(points, recalculate) {
if ((cachedVoronoiDiagram === null) || recalculate) {
let vbbox = {xl: LIMIT_MIN, xr: LIMIT_MAX, yt: LIMIT_MIN, yb: LIMIT_MAX};
let sites = []
for (let p of points) {
sites.push(biomePointToVoronoiPoint(p));
}
let voronoi = new Voronoi();
let diagram = null;
if (cachedVoronoiDiagram && recalculate) {
diagram = cachedVoronoiDiagram;
// This should improve performance
voronoi.recycle(diagram);
}
diagram = voronoi.compute(sites, vbbox);
cachedVoronoiDiagram = diagram;
return diagram;
} else {
return cachedVoronoiDiagram;
}
}
function getDrawContext() {
let canvas = document.getElementById("voronoiCanvas");
// TODO: Check for getContext support of browser
return canvas.getContext("2d");
}
function drawInit() {
let context = getDrawContext();
context.scale(voronoiCanvas.width/(LIMIT_MAX-LIMIT_MIN), voronoiCanvas.height/(LIMIT_MAX-LIMIT_MIN));
context.translate(-LIMIT_MIN, -LIMIT_MIN);
}
function getRenderedPoints(y) {
let points = [];
for (let p=0; p<biomePoints.length; p++) {
let point = biomePoints[p];
if (y >= point.min_y && y <= point.max_y) {
points.push(point);
}
}
return points;
}
function draw(y, recalculate) {
let context = getDrawContext();
// Clear draw area
context.fillStyle = clearColor;
context.fillRect(DRAW_MIN, DRAW_MIN, DRAW_MAX-DRAW_MIN, DRAW_MAX-DRAW_MIN);
let points = getRenderedPoints(y);
let diagram = getVoronoiDiagram(points, recalculate);
// Render cell colors
let colors = cellColors;
for (let c=0; c<diagram.cells.length; c++) {
let cell = diagram.cells[c]
let ccol = c % colors.length;
context.fillStyle = colors[ccol];
context.beginPath();
for (let h=0; h<cell.halfedges.length; h++) {
let halfedge = cell.halfedges[h]
let start = halfedge.getStartpoint()
let end = halfedge.getEndpoint()
if (h === 0) {
context.moveTo(start.x, start.y);
} else {
context.lineTo(start.x, start.y);
}
context.lineTo(end.x, end.y);
}
context.closePath();
context.fill();
}
// If there's only 1 cell, we have to manually colorize it because
// the Voronoi script doesn't return that area in this special case.
if (points.length === 1 && diagram.cells.length === 1) {
// 1 cell means the whole area is filled
context.fillStyle = colors[0];
context.fillRect(DRAW_MIN, DRAW_MIN, DRAW_MAX-DRAW_MIN, DRAW_MAX-DRAW_MIN);
}
if (points.length > 0) {
//putAxes(context);
putGrid(context);
}
// Render Voronoi cell edges
context.lineWidth = 2;
for (let e=0; e<diagram.edges.length; e++) {
let edge = diagram.edges[e];
if (edge.rSite === null) {
context.strokeStyle = "transparent";
} else {
context.strokeStyle = edgeColor;
}
context.beginPath();
context.moveTo(edge.va.x, edge.va.y);
context.lineTo(edge.vb.x, edge.vb.y);
context.closePath();
context.stroke();
}
let selElemID = null;
if (biomeSelector.selectedIndex !== -1) {
let selElem = biomeSelector.options[biomeSelector.selectedIndex];
let strID = selElem.id;
if (strID.startsWith("biome_list_element_")) {
let slice = strID.slice(19);
if (slice) {
selElemID = +slice;
}
}
}
// Render biome points
for (let p=0; p<points.length; p++) {
let point = points[p]
let pointID = point.id;
// Highlight selected point
if (selElemID !== null && pointID === selElemID) {
context.fillStyle = pointColorSelected;
} else {
context.fillStyle = pointColor;
}
putPoint(context, point);
}
// Render a special message if there are no biomes
if (points.length === 0) {
context.textAlign = "center";
context.fillStyle = "black";
context.textBaseline = "middle";
let msg;
if (biomePoints.length === 0) {
msg = "No biomes.";
} else {
msg = "No biomes in this Y layer.";
}
context.fillText(msg, 50, 50);
}
}
function rewriteBiomeSelector() {
biomeSelector.innerHTML = "";
for (let b=0; b<biomePoints.length; b++) {
let num = b+1;
let newElem = document.createElement("option");
newElem.value = num;
newElem.id = "biome_list_element_" + biomePoints[b].id;
let newElemText = document.createTextNode(biomePoints[b].name);
newElem.append(newElemText);
biomeSelector.append(newElem);
}
}
function updateWidgetStates() {
if (biomePoints.length === 0 || biomeSelector.selectedIndex === -1) {
removeBiomeButton.disabled = "disabled";
inputHeat.disabled = "disabled";
inputHumidity.disabled = "disabled";
inputMinY.disabled = "disabled";
inputMaxY.disabled = "disabled";
} else {
removeBiomeButton.disabled = "";
inputHeat.disabled = "";
inputHumidity.disabled = "";
inputMinY.disabled = "";
inputMaxY.disabled = "";
if (biomeSelector.selectedIndex !== -1) {
let selected = biomeSelector.options[biomeSelector.selectedIndex];
let point = biomePoints[biomeSelector.selectedIndex];
inputHeat.value = point.heat;
inputHumidity.value = point.humidity;
inputMinY.value = point.min_y;
inputMaxY.value = point.max_y;
}
}
}
biomeSelector.onchange = function() {
draw(getViewY(), false);
if (biomeSelector.selectedIndex !== -1) {
let selected = biomeSelector.options[biomeSelector.selectedIndex];
let point = biomePoints[biomeSelector.selectedIndex];
inputHeat.value = point.heat;
inputHumidity.value = point.humidity;
inputMinY.value = point.min_y;
inputMaxY.value = point.max_y;
}
updateWidgetStates();
}
function onChangeBiomeValueWidget(pointField, value) {
if (value === null) {
return;
}
if (biomeSelector.selectedIndex === -1) {
return;
}
let selected = biomeSelector.options[biomeSelector.selectedIndex];
if (selected === null) {
return;
}
let point = biomePoints[biomeSelector.selectedIndex];
point[pointField] = +value;
point.name = generateBiomeName(point.heat, point.humidity);
selected.innerText = point.name;
draw(getViewY(), true);
}
inputHeat.onchange = function() {
onChangeBiomeValueWidget("heat", this.value);
}
inputHumidity.onchange = function() {
onChangeBiomeValueWidget("humidity", this.value);
}
inputMinY.onchange = function() {
onChangeBiomeValueWidget("min_y", this.value);
}
inputMaxY.onchange = function() {
onChangeBiomeValueWidget("max_y", this.value);
}
inputViewY.onchange = function() {
draw(getViewY(), true);
}
addBiomeButton.onclick = function() {
let he = Math.round(Math.random()*100);
let hu = Math.round(Math.random()*100);
let newPoint = {
id: lastBiomeID,
name: generateBiomeName(he, hu),
heat: he,
min_y: MIN_Y_DEFAULT,
max_y: MAX_Y_DEFAULT,
humidity: hu,
};
biomePoints.push(newPoint);
let num = biomePoints.length
let newElem = document.createElement("option");
newElem.id = "biome_list_element_" + lastBiomeID;
newElem.value = "" + num;
let newElemText = document.createTextNode(newPoint.name);
newElem.append(newElemText);
biomeSelector.append(newElem);
newElem.selected = "selected";
draw(getViewY(), true);
updateWidgetStates();
lastBiomeID++;
}
removeBiomeButton.onclick = function() {
if (biomeSelector.selectedOptions.length === 0) {
return;
}
let firstIndex = null;
for (let o=0; o<biomeSelector.selectedOptions.length; o++) {
let opt = biomeSelector.selectedOptions[o]
let index = opt.index
if (firstIndex === null) {
firstIndex = index;
}
biomePoints.splice(index, 1);
opt.remove();
}
if (firstIndex !== null && biomePoints.length > 0) {
let newIndex = firstIndex-1;
if (newIndex < 0) {
newIndex = 0;
}
biomeSelector.options[newIndex].selected = "selected";
}
draw(getViewY(), true);
updateWidgetStates();
}
function selectPoint(point) {
for (let elem of biomeSelector.options) {
let strID = elem.id;
let elemID = null;
if (strID.startsWith("biome_list_element_")) {
let slice = strID.slice(19);
if (slice) {
elemID = +slice;
}
}
if (elemID !== null) {
if (point.id === elemID) {
if (elem.selected) {
console.log("Already selected!");
return [true, true];
}
elem.selected = "selected";
draw(getViewY(), true);
updateWidgetStates();
return [true, false];
}
}
}
return [false, false];
}
function getDistance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1)**2 + (y2 - y1)**2);
}
function canvasPixelCoordsToBiomeCoords(x, y) {
let w = (voronoiCanvas.width/(LIMIT_MAX-LIMIT_MIN));
let h = (voronoiCanvas.height/(LIMIT_MAX-LIMIT_MIN));
let heat = Math.round((x + LIMIT_MIN * w) / w);
let humidity = Math.round((y + LIMIT_MIN * w) / w);
return [heat, humidity];
}
function biomeCoordsToCanvasPixelCoords(heat, humidity) {
let w = (voronoiCanvas.width/(LIMIT_MAX-LIMIT_MIN));
let h = (voronoiCanvas.height/(LIMIT_MAX-LIMIT_MIN));
let pixelX = heat * w - LIMIT_MIN * w;
let pixelY = humidity * h - LIMIT_MIN * h;
return [pixelX, pixelY];
}
function getNearestPointFromCanvasPos(x, y, maxDist) {
let nearestPoint = null;
let nearestDist = null;
let points = getRenderedPoints(getViewY());
for (let i=0; i<points.length; i++) {
let point = points[i];
let [pixelX, pixelY] = biomeCoordsToCanvasPixelCoords(point.heat, point.humidity);
let dist = getDistance(x, y, pixelX, pixelY);
if (nearestPoint === null) {
nearestPoint = point;
nearestDist = dist;
} else if (dist < nearestDist) {
nearestPoint = point;
nearestDist = dist;
}
}
if (nearestDist < maxDist) {
return nearestPoint;
} else {
return null;
}
}
let mouseIsDown = false;
let dragDropPointID = null;
let dragDropPos = null;
function updatePointWhenDragged(pointID) {
if (pointID !== null) {
let selectedPoint = null;
let points = getRenderedPoints(getViewY());
for (let i=0; i<points.length; i++) {
if (points[i].id === dragDropPointID) {
selectedPoint = points[i];
let [newHeat, newHumidity] = canvasPixelCoordsToBiomeCoords(event.offsetX, event.offsetY);
selectedPoint.heat = newHeat;
selectedPoint.humidity = newHumidity;
selectedPoint.name = generateBiomeName(newHeat, newHumidity);
draw(getViewY(), true);
updateWidgetStates();
for (let elem of biomeSelector.options) {
let strID = elem.id;
let elemID = null;
if (strID.startsWith("biome_list_element_")) {
let slice = strID.slice(19);
if (slice) {
elemID = +slice;
}
}
if (elemID !== null && points[i].id === elemID) {
elem.innerText = selectedPoint.name;
break;
}
}
return;
}
}
}
}
voronoiCanvas.onmousemove = function(event) {
// drag-n-drop
if (mouseIsDown) {
updatePointWhenDragged(dragDropPointID);
}
// show coordinates
let [heat, humidity] = canvasPixelCoordsToBiomeCoords(event.offsetX, event.offsetY);
let html = "heat="+heat+"; humidity="+humidity;
coordinateDisplay.innerHTML = html;
}
voronoiCanvas.onmouseenter = function(event) {
// show coordinates
let [heat, humidity] = canvasPixelCoordsToBiomeCoords(event.offsetX, event.offsetY);
let html = "heat="+heat+"; humidity="+humidity;
coordinateDisplay.innerHTML = html;
}
voronoiCanvas.onmousedown = function(event) {
// select point by clicking.
// initiate drag-n-drop if already selected.
mouseIsDown = true;
let nearest = getNearestPointFromCanvasPos(event.offsetX, event.offsetY, 25);
if (nearest !== null) {
let success, alreadySelected
[success, alreadySelected] = selectPoint(nearest);
if (alreadySelected) {
dragDropPointID = nearest.id;
}
}
}
voronoiCanvas.onmouseup = function(event) {
// end drag-n-drop
updatePointWhenDragged(dragDropPointID);
mouseIsDown = false;
dragDropPos = null;
dragDropPointID = null;
}
voronoiCanvas.onmouseleave = function() {
// end drag-n-drop
mouseIsDown = false;
dragDropPointID = null;
dragDropPos = null;
coordinateDisplay.innerHTML = "&nbsp;";
}
window.addEventListener("load", drawInit);
window.addEventListener("load", function() {
draw(getViewY(), true);
})
window.addEventListener("load", rewriteBiomeSelector);
window.addEventListener("load", updateWidgetStates);