Phase 2: Terrain textures, bezier routing, drag-paint, HTTPS
- svg/renderer.ts: Full Canvas rendering engine with terrain textures (trees for forest, waves for water, peaks for mountains, contour lines for hills, hatch for farmland, buildings for settlements) - Linear features: paired-edge bezier routing (straight-through, curves, dead-ends), river wobble, proper coastline along hex edges - Drag-paint: click-and-drag in Paint mode paints multiple hexes, disables map panning during paint gesture - NGINX reverse proxy + Let's Encrypt cert for hexifyer.davoryn.de - Refactored hex-layer.ts to delegate rendering to renderer module Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
public/tiles
Symbolic link
1
public/tiles
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/var/www/kiepenkerl/tiles
|
||||||
45
src/main.ts
45
src/main.ts
@@ -1,7 +1,7 @@
|
|||||||
import './style/main.css';
|
import './style/main.css';
|
||||||
import { initMap } from './map/map-init.js';
|
import { initMap } from './map/map-init.js';
|
||||||
import { HexOverlayLayer } from './map/hex-layer.js';
|
import { HexOverlayLayer } from './map/hex-layer.js';
|
||||||
import { attachHexInteraction } from './map/hex-interaction.js';
|
import { attachHexInteraction, type HexClickEvent } from './map/hex-interaction.js';
|
||||||
import { HexMap } from '../core/hex-map.js';
|
import { HexMap } from '../core/hex-map.js';
|
||||||
import type { AxialCoord, TerrainType } from '../core/types.js';
|
import type { AxialCoord, TerrainType } from '../core/types.js';
|
||||||
import { createSidebar } from './ui/sidebar.js';
|
import { createSidebar } from './ui/sidebar.js';
|
||||||
@@ -76,23 +76,24 @@ function rebuildHexLayer(showGrid: boolean, opacity: number) {
|
|||||||
reattachInteraction();
|
reattachInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hex Interaction ---
|
// --- Interaction handlers ---
|
||||||
let detachInteraction: (() => void) | null = null;
|
|
||||||
|
|
||||||
function reattachInteraction() {
|
function handleSelect(event: HexClickEvent) {
|
||||||
detachInteraction?.();
|
|
||||||
detachInteraction = attachHexInteraction(map, hexSize, origin, (event) => {
|
|
||||||
if (currentMode === 'select') {
|
|
||||||
selectedHex = event.coord;
|
selectedHex = event.coord;
|
||||||
hexLayer.setSelectedHex(selectedHex);
|
hexLayer.setSelectedHex(selectedHex);
|
||||||
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
||||||
} else if (currentMode === 'paint' && selectedTerrain) {
|
}
|
||||||
|
|
||||||
|
function handlePaint(event: HexClickEvent) {
|
||||||
|
if (!selectedTerrain) return;
|
||||||
hexMap.setBase(event.coord, selectedTerrain.id);
|
hexMap.setBase(event.coord, selectedTerrain.id);
|
||||||
selectedHex = event.coord;
|
|
||||||
hexLayer.setSelectedHex(selectedHex);
|
|
||||||
hexLayer.redraw();
|
hexLayer.redraw();
|
||||||
|
selectedHex = event.coord;
|
||||||
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
||||||
} else if (currentMode === 'feature' && selectedTerrain) {
|
}
|
||||||
|
|
||||||
|
function handleFeature(event: HexClickEvent) {
|
||||||
|
if (!selectedTerrain) return;
|
||||||
const coord = event.coord;
|
const coord = event.coord;
|
||||||
const terrain = hexMap.getTerrain(coord);
|
const terrain = hexMap.getTerrain(coord);
|
||||||
const existing = terrain.features.find(f => f.terrainId === selectedTerrain!.id);
|
const existing = terrain.features.find(f => f.terrainId === selectedTerrain!.id);
|
||||||
@@ -101,9 +102,7 @@ function reattachInteraction() {
|
|||||||
|
|
||||||
hexMap.setFeature(coord, selectedTerrain.id, newMask);
|
hexMap.setFeature(coord, selectedTerrain.id, newMask);
|
||||||
|
|
||||||
// Enforce edge constraints
|
|
||||||
if (newMask > currentMask) {
|
if (newMask > currentMask) {
|
||||||
// An edge was added — ensure neighbor has continuation
|
|
||||||
const addedEdgeMask = edgeMask(event.edge);
|
const addedEdgeMask = edgeMask(event.edge);
|
||||||
const actions = enforceEdgeConstraints(hexMap, coord, selectedTerrain.id, addedEdgeMask);
|
const actions = enforceEdgeConstraints(hexMap, coord, selectedTerrain.id, addedEdgeMask);
|
||||||
applyConstraintActions(hexMap, actions);
|
applyConstraintActions(hexMap, actions);
|
||||||
@@ -114,7 +113,25 @@ function reattachInteraction() {
|
|||||||
hexLayer.redraw();
|
hexLayer.redraw();
|
||||||
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// --- Hex Interaction ---
|
||||||
|
let detachInteraction: (() => void) | null = null;
|
||||||
|
|
||||||
|
function reattachInteraction() {
|
||||||
|
detachInteraction?.();
|
||||||
|
detachInteraction = attachHexInteraction(
|
||||||
|
map, hexSize, origin,
|
||||||
|
// Click handler
|
||||||
|
(event) => {
|
||||||
|
if (currentMode === 'select') handleSelect(event);
|
||||||
|
else if (currentMode === 'paint') handlePaint(event);
|
||||||
|
else if (currentMode === 'feature') handleFeature(event);
|
||||||
|
},
|
||||||
|
// Drag-paint handler (only active in paint mode)
|
||||||
|
(event) => {
|
||||||
|
if (currentMode === 'paint') handlePaint(event);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
reattachInteraction();
|
reattachInteraction();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import type { AxialCoord } from '../../core/types.js';
|
import type { AxialCoord } from '../../core/types.js';
|
||||||
import { HexEdge } from '../../core/types.js';
|
import { HexEdge } from '../../core/types.js';
|
||||||
import { pixelToAxial, closestEdge, axialToPixel } from '../../core/coords.js';
|
import { pixelToAxial, closestEdge, axialToPixel, coordKey } from '../../core/coords.js';
|
||||||
import { toPixel } from './map-init.js';
|
import { toPixel } from './map-init.js';
|
||||||
|
|
||||||
export interface HexClickEvent {
|
export interface HexClickEvent {
|
||||||
@@ -13,35 +13,79 @@ export interface HexClickEvent {
|
|||||||
|
|
||||||
export type HexClickHandler = (event: HexClickEvent) => void;
|
export type HexClickHandler = (event: HexClickEvent) => void;
|
||||||
|
|
||||||
|
function buildEvent(map: L.Map, latlng: L.LatLng, hexSize: number, origin: { x: number; y: number }): HexClickEvent {
|
||||||
|
const pixel = toPixel(map, latlng);
|
||||||
|
const pixelCoord = { x: pixel[0], y: pixel[1] };
|
||||||
|
const coord = pixelToAxial(pixelCoord, hexSize, origin);
|
||||||
|
const hexCenter = axialToPixel(coord, hexSize, origin);
|
||||||
|
const edge = closestEdge(hexCenter, hexSize, pixelCoord);
|
||||||
|
return { coord, edge, latlng, pixelOnImage: pixel };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach hex click detection to a Leaflet map.
|
* Attach hex interaction to a Leaflet map.
|
||||||
* Translates map clicks to hex coordinates + closest edge.
|
* Supports both click and drag-paint (mousedown + mousemove).
|
||||||
*/
|
*/
|
||||||
export function attachHexInteraction(
|
export function attachHexInteraction(
|
||||||
map: L.Map,
|
map: L.Map,
|
||||||
hexSize: number,
|
hexSize: number,
|
||||||
origin: { x: number; y: number },
|
origin: { x: number; y: number },
|
||||||
handler: HexClickHandler,
|
onClick: HexClickHandler,
|
||||||
|
onDragPaint?: HexClickHandler,
|
||||||
): () => void {
|
): () => void {
|
||||||
const onClick = (e: L.LeafletMouseEvent) => {
|
let dragging = false;
|
||||||
const pixel = toPixel(map, e.latlng);
|
let lastDragKey = '';
|
||||||
const pixelCoord = { x: pixel[0], y: pixel[1] };
|
|
||||||
const coord = pixelToAxial(pixelCoord, hexSize, origin);
|
|
||||||
const hexCenter = axialToPixel(coord, hexSize, origin);
|
|
||||||
const edge = closestEdge(hexCenter, hexSize, pixelCoord);
|
|
||||||
|
|
||||||
handler({
|
const handleClick = (e: L.LeafletMouseEvent) => {
|
||||||
coord,
|
if (dragging) return; // Don't fire click at end of drag
|
||||||
edge,
|
onClick(buildEvent(map, e.latlng, hexSize, origin));
|
||||||
latlng: e.latlng,
|
|
||||||
pixelOnImage: pixel,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
map.on('click', onClick);
|
const handleMouseDown = (e: L.LeafletMouseEvent) => {
|
||||||
|
if (!onDragPaint) return;
|
||||||
|
dragging = true;
|
||||||
|
lastDragKey = '';
|
||||||
|
|
||||||
|
// Disable map dragging while painting
|
||||||
|
map.dragging.disable();
|
||||||
|
|
||||||
|
const event = buildEvent(map, e.latlng, hexSize, origin);
|
||||||
|
lastDragKey = coordKey(event.coord);
|
||||||
|
onDragPaint(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: L.LeafletMouseEvent) => {
|
||||||
|
if (!dragging || !onDragPaint) return;
|
||||||
|
|
||||||
|
const event = buildEvent(map, e.latlng, hexSize, origin);
|
||||||
|
const key = coordKey(event.coord);
|
||||||
|
|
||||||
|
// Only fire if we moved to a new hex
|
||||||
|
if (key !== lastDragKey) {
|
||||||
|
lastDragKey = key;
|
||||||
|
onDragPaint(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (dragging) {
|
||||||
|
dragging = false;
|
||||||
|
map.dragging.enable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('click', handleClick);
|
||||||
|
map.on('mousedown', handleMouseDown);
|
||||||
|
map.on('mousemove', handleMouseMove);
|
||||||
|
map.on('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off('click', onClick);
|
map.off('click', handleClick);
|
||||||
|
map.off('mousedown', handleMouseDown);
|
||||||
|
map.off('mousemove', handleMouseMove);
|
||||||
|
map.off('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
map.dragging.enable();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import type { AxialCoord, HexTerrain } from '../../core/types.js';
|
import type { AxialCoord } from '../../core/types.js';
|
||||||
import { axialToPixel, hexVertices, hexHeight, hexWidth, computeHexGeometry } from '../../core/coords.js';
|
import { axialToPixel, computeHexGeometry } from '../../core/coords.js';
|
||||||
import { getHexesInBounds, type PixelBounds } from '../../core/hex-grid.js';
|
import { getHexesInBounds, type PixelBounds } from '../../core/hex-grid.js';
|
||||||
import { getTerrainType } from '../../core/terrain.js';
|
|
||||||
import type { HexMap } from '../../core/hex-map.js';
|
import type { HexMap } from '../../core/hex-map.js';
|
||||||
import { connectedEdges } from '../../core/edge-connectivity.js';
|
import { renderHex } from '../svg/renderer.js';
|
||||||
|
|
||||||
export interface HexLayerOptions extends L.GridLayerOptions {
|
export interface HexLayerOptions extends L.GridLayerOptions {
|
||||||
hexSize: number;
|
hexSize: number;
|
||||||
@@ -17,6 +16,7 @@ export interface HexLayerOptions extends L.GridLayerOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Leaflet GridLayer that renders the hex overlay using Canvas.
|
* Leaflet GridLayer that renders the hex overlay using Canvas.
|
||||||
|
* Delegates all hex rendering to svg/renderer.ts.
|
||||||
*/
|
*/
|
||||||
export class HexOverlayLayer extends L.GridLayer {
|
export class HexOverlayLayer extends L.GridLayer {
|
||||||
private hexSize: number;
|
private hexSize: number;
|
||||||
@@ -58,11 +58,9 @@ export class HexOverlayLayer extends L.GridLayer {
|
|||||||
canvas.height = tileSize.y;
|
canvas.height = tileSize.y;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
// Convert tile coords to pixel bounds on the source image
|
|
||||||
const nwPoint = coords.scaleBy(tileSize);
|
const nwPoint = coords.scaleBy(tileSize);
|
||||||
const sePoint = nwPoint.add(tileSize);
|
const sePoint = nwPoint.add(tileSize);
|
||||||
|
|
||||||
// At the current zoom, convert tile pixel coords to source image coords
|
|
||||||
const zoom = coords.z;
|
const zoom = coords.z;
|
||||||
const maxZoom = this._map?.getMaxZoom() ?? 6;
|
const maxZoom = this._map?.getMaxZoom() ?? 6;
|
||||||
const scale = Math.pow(2, maxZoom - zoom);
|
const scale = Math.pow(2, maxZoom - zoom);
|
||||||
@@ -74,153 +72,28 @@ export class HexOverlayLayer extends L.GridLayer {
|
|||||||
maxY: sePoint.y * scale,
|
maxY: sePoint.y * scale,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find hexes overlapping this tile
|
|
||||||
const hexCoords = getHexesInBounds(bounds, this.hexSize, this.origin);
|
const hexCoords = getHexesInBounds(bounds, this.hexSize, this.origin);
|
||||||
|
|
||||||
// Draw each hex
|
|
||||||
for (const coord of hexCoords) {
|
for (const coord of hexCoords) {
|
||||||
const terrain = this.hexMap.getTerrain(coord);
|
const terrain = this.hexMap.getTerrain(coord);
|
||||||
const pixelCenter = axialToPixel(coord, this.hexSize, this.origin);
|
const pixelCenter = axialToPixel(coord, this.hexSize, this.origin);
|
||||||
|
|
||||||
// Convert source image pixel coords to tile-local coords
|
|
||||||
const localX = (pixelCenter.x - bounds.minX) / scale;
|
const localX = (pixelCenter.x - bounds.minX) / scale;
|
||||||
const localY = (pixelCenter.y - bounds.minY) / scale;
|
const localY = (pixelCenter.y - bounds.minY) / scale;
|
||||||
const localSize = this.hexSize / scale;
|
const localSize = this.hexSize / scale;
|
||||||
|
|
||||||
this.drawHex(ctx, localX, localY, localSize, terrain, coord);
|
const geom = computeHexGeometry(localX, localY, localSize);
|
||||||
|
const isSelected = this._selectedHex !== null &&
|
||||||
|
this._selectedHex.q === coord.q &&
|
||||||
|
this._selectedHex.r === coord.r;
|
||||||
|
|
||||||
|
renderHex(ctx, geom, terrain, {
|
||||||
|
opacity: this._hexOpacity,
|
||||||
|
showGrid: this._showGrid,
|
||||||
|
selected: isSelected,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawHex(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
cx: number,
|
|
||||||
cy: number,
|
|
||||||
size: number,
|
|
||||||
terrain: HexTerrain,
|
|
||||||
coord: AxialCoord,
|
|
||||||
): void {
|
|
||||||
if (size < 2) return; // Too small to render
|
|
||||||
|
|
||||||
const vertices = hexVertices(cx, cy, size);
|
|
||||||
const geom = computeHexGeometry(cx, cy, size);
|
|
||||||
|
|
||||||
// Draw hex path
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(vertices[0].x, vertices[0].y);
|
|
||||||
for (let i = 1; i < 6; i++) {
|
|
||||||
ctx.lineTo(vertices[i].x, vertices[i].y);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
|
|
||||||
// Fill with base terrain color
|
|
||||||
const baseType = getTerrainType(terrain.base);
|
|
||||||
if (baseType) {
|
|
||||||
ctx.globalAlpha = this._hexOpacity;
|
|
||||||
ctx.fillStyle = baseType.color;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw linear features
|
|
||||||
const sortedFeatures = [...terrain.features].sort((a, b) => {
|
|
||||||
const ta = getTerrainType(a.terrainId);
|
|
||||||
const tb = getTerrainType(b.terrainId);
|
|
||||||
return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const feature of sortedFeatures) {
|
|
||||||
const type = getTerrainType(feature.terrainId);
|
|
||||||
if (!type) continue;
|
|
||||||
|
|
||||||
const edges = connectedEdges(feature.edgeMask);
|
|
||||||
if (edges.length === 0) continue;
|
|
||||||
|
|
||||||
ctx.strokeStyle = type.color;
|
|
||||||
ctx.lineWidth = Math.max(1, size / 8);
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.globalAlpha = 0.9;
|
|
||||||
|
|
||||||
if (feature.terrainId === 'road') {
|
|
||||||
ctx.setLineDash([size / 4, size / 6]);
|
|
||||||
} else {
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (feature.terrainId === 'coastline') {
|
|
||||||
// Coastline: draw along hex edges
|
|
||||||
ctx.lineWidth = Math.max(2, size / 5);
|
|
||||||
for (const edge of edges) {
|
|
||||||
const v1 = vertices[edge];
|
|
||||||
const v2 = vertices[(edge + 1) % 6];
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(v1.x, v1.y);
|
|
||||||
ctx.lineTo(v2.x, v2.y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
} else if (edges.length === 1) {
|
|
||||||
// Dead-end: edge midpoint to center
|
|
||||||
const mp = geom.edgeMidpoints[edges[0]];
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(mp.x, mp.y);
|
|
||||||
ctx.lineTo(cx, cy);
|
|
||||||
ctx.stroke();
|
|
||||||
// Terminus dot
|
|
||||||
ctx.fillStyle = type.color;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, Math.max(1, size / 10), 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
} else {
|
|
||||||
// Connect all edges through center with bezier curves
|
|
||||||
for (const edge of edges) {
|
|
||||||
const mp = geom.edgeMidpoints[edge];
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(mp.x, mp.y);
|
|
||||||
// Bezier through ~halfway to center for a slight curve
|
|
||||||
const cpX = (mp.x + cx) / 2;
|
|
||||||
const cpY = (mp.y + cy) / 2;
|
|
||||||
ctx.quadraticCurveTo(cpX, cpY, cx, cy);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grid outline
|
|
||||||
if (this._showGrid && size > 4) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(vertices[0].x, vertices[0].y);
|
|
||||||
for (let i = 1; i < 6; i++) {
|
|
||||||
ctx.lineTo(vertices[i].x, vertices[i].y);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
|
|
||||||
ctx.lineWidth = 0.5;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection highlight
|
|
||||||
if (
|
|
||||||
this._selectedHex &&
|
|
||||||
this._selectedHex.q === coord.q &&
|
|
||||||
this._selectedHex.r === coord.r
|
|
||||||
) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(vertices[0].x, vertices[0].y);
|
|
||||||
for (let i = 1; i < 6; i++) {
|
|
||||||
ctx.lineTo(vertices[i].x, vertices[i].y);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.strokeStyle = '#fff';
|
|
||||||
ctx.lineWidth = Math.max(1, size / 10);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.lineWidth = Math.max(0.5, size / 20);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
521
src/svg/renderer.ts
Normal file
521
src/svg/renderer.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Canvas-based hex renderer with terrain textures and linear feature routing.
|
||||||
|
*
|
||||||
|
* This module handles all visual rendering of hex tiles:
|
||||||
|
* - Area fill + texture patterns (trees, waves, peaks, etc.)
|
||||||
|
* - Linear feature bezier curves (roads, rivers, coastlines)
|
||||||
|
* - Grid outlines and selection highlights
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HexTerrain, HexGeometry, PixelCoord } from '../../core/types.js';
|
||||||
|
import { HexEdge } from '../../core/types.js';
|
||||||
|
import { getTerrainType } from '../../core/terrain.js';
|
||||||
|
import { connectedEdges } from '../../core/edge-connectivity.js';
|
||||||
|
|
||||||
|
// --- Area texture renderers ---
|
||||||
|
|
||||||
|
type TextureRenderer = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
geom: HexGeometry,
|
||||||
|
color: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
function drawTreeSymbols(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void {
|
||||||
|
const { cx, cy, size } = geom;
|
||||||
|
const s = size * 0.18;
|
||||||
|
const spacing = size * 0.42;
|
||||||
|
|
||||||
|
// Place trees in a rough grid clipped to the hex
|
||||||
|
ctx.save();
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
|
||||||
|
const offsets = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: -spacing, y: -spacing * 0.7 },
|
||||||
|
{ x: spacing, y: -spacing * 0.7 },
|
||||||
|
{ x: -spacing * 0.5, y: spacing * 0.6 },
|
||||||
|
{ x: spacing * 0.5, y: spacing * 0.6 },
|
||||||
|
{ x: 0, y: -spacing * 1.2 },
|
||||||
|
{ x: -spacing, y: spacing * 0.2 },
|
||||||
|
{ x: spacing, y: spacing * 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const off of offsets) {
|
||||||
|
const tx = cx + off.x;
|
||||||
|
const ty = cy + off.y;
|
||||||
|
// Simple triangle tree
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tx, ty - s * 1.3);
|
||||||
|
ctx.lineTo(tx - s, ty + s * 0.4);
|
||||||
|
ctx.lineTo(tx + s, ty + s * 0.4);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = lighten(color, 15);
|
||||||
|
ctx.fill();
|
||||||
|
// Trunk
|
||||||
|
ctx.fillStyle = '#4a3520';
|
||||||
|
ctx.fillRect(tx - s * 0.15, ty + s * 0.4, s * 0.3, s * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWavePattern(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void {
|
||||||
|
const { cx, cy, size } = geom;
|
||||||
|
ctx.save();
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
|
||||||
|
const lineColor = lighten(color, 20);
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.lineWidth = Math.max(0.5, size * 0.02);
|
||||||
|
ctx.globalAlpha = 0.5;
|
||||||
|
|
||||||
|
const spacing = size * 0.3;
|
||||||
|
const amplitude = size * 0.06;
|
||||||
|
const waveLen = size * 0.25;
|
||||||
|
|
||||||
|
for (let row = -3; row <= 3; row++) {
|
||||||
|
const baseY = cy + row * spacing;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let dx = -size * 1.2; dx <= size * 1.2; dx += 2) {
|
||||||
|
const x = cx + dx;
|
||||||
|
const y = baseY + Math.sin((dx / waveLen) * Math.PI * 2) * amplitude;
|
||||||
|
if (dx === -size * 1.2) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMountainPeaks(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void {
|
||||||
|
const { cx, cy, size } = geom;
|
||||||
|
ctx.save();
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
|
||||||
|
const peakColor = lighten(color, 25);
|
||||||
|
const shadowColor = darken(color, 15);
|
||||||
|
const s = size * 0.25;
|
||||||
|
|
||||||
|
const peaks = [
|
||||||
|
{ x: cx, y: cy - size * 0.1, scale: 1.2 },
|
||||||
|
{ x: cx - size * 0.35, y: cy + size * 0.15, scale: 0.8 },
|
||||||
|
{ x: cx + size * 0.35, y: cy + size * 0.2, scale: 0.9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const peak of peaks) {
|
||||||
|
const ps = s * peak.scale;
|
||||||
|
// Shadow side
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(peak.x, peak.y - ps * 1.2);
|
||||||
|
ctx.lineTo(peak.x + ps * 0.9, peak.y + ps * 0.5);
|
||||||
|
ctx.lineTo(peak.x, peak.y + ps * 0.5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = shadowColor;
|
||||||
|
ctx.fill();
|
||||||
|
// Light side
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(peak.x, peak.y - ps * 1.2);
|
||||||
|
ctx.lineTo(peak.x - ps * 0.9, peak.y + ps * 0.5);
|
||||||
|
ctx.lineTo(peak.x, peak.y + ps * 0.5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = peakColor;
|
||||||
|
ctx.fill();
|
||||||
|
// Snow cap
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(peak.x, peak.y - ps * 1.2);
|
||||||
|
ctx.lineTo(peak.x - ps * 0.25, peak.y - ps * 0.6);
|
||||||
|
ctx.lineTo(peak.x + ps * 0.25, peak.y - ps * 0.6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = '#e8e8e8';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHillContours(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void {
|
||||||
|
const { cx, cy, size } = geom;
|
||||||
|
ctx.save();
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
|
||||||
|
ctx.strokeStyle = darken(color, 15);
|
||||||
|
ctx.lineWidth = Math.max(0.5, size * 0.025);
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
|
||||||
|
// Concentric arcs suggesting rounded hills
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = size * (0.2 + i * 0.2);
|
||||||
|
const offy = -size * 0.1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy + offy, r, Math.PI * 1.15, Math.PI * 1.85);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second hill
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const r = size * (0.15 + i * 0.18);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + size * 0.3, cy + size * 0.2, r, Math.PI * 1.1, Math.PI * 1.9);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFarmlandHatch(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void {
|
||||||
|
const { cx, cy, size } = geom;
|
||||||
|
ctx.save();
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
|
||||||
|
ctx.strokeStyle = darken(color, 20);
|
||||||
|
ctx.lineWidth = Math.max(0.3, size * 0.015);
|
||||||
|
ctx.globalAlpha = 0.4;
|
||||||
|
|
||||||
|
const spacing = size * 0.2;
|
||||||
|
// Parallel lines (field rows)
|
||||||
|
for (let i = -6; i <= 6; i++) {
|
||||||
|
const y = cy + i * spacing;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - size * 1.2, y);
|
||||||
|
ctx.lineTo(cx + size * 1.2, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSettlementSymbol(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void {
|
||||||
|
const { cx, cy, size } = geom;
|
||||||
|
ctx.save();
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
|
||||||
|
const s = size * 0.15;
|
||||||
|
const buildingColor = lighten(color, 20);
|
||||||
|
const roofColor = darken(color, 10);
|
||||||
|
|
||||||
|
// Central building
|
||||||
|
ctx.fillStyle = buildingColor;
|
||||||
|
ctx.fillRect(cx - s * 1.2, cy - s * 0.3, s * 2.4, s * 1.5);
|
||||||
|
// Roof
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - s * 1.5, cy - s * 0.3);
|
||||||
|
ctx.lineTo(cx, cy - s * 1.5);
|
||||||
|
ctx.lineTo(cx + s * 1.5, cy - s * 0.3);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = roofColor;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Side buildings
|
||||||
|
for (const dx of [-size * 0.3, size * 0.3]) {
|
||||||
|
ctx.fillStyle = buildingColor;
|
||||||
|
ctx.fillRect(cx + dx - s * 0.6, cy + s * 0.3, s * 1.2, s * 1.0);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + dx - s * 0.8, cy + s * 0.3);
|
||||||
|
ctx.lineTo(cx + dx, cy - s * 0.3);
|
||||||
|
ctx.lineTo(cx + dx + s * 0.8, cy + s * 0.3);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = roofColor;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXTURE_RENDERERS: Record<string, TextureRenderer> = {
|
||||||
|
forest: drawTreeSymbols,
|
||||||
|
ocean: drawWavePattern,
|
||||||
|
lake: drawWavePattern,
|
||||||
|
mountains: drawMountainPeaks,
|
||||||
|
hills: drawHillContours,
|
||||||
|
farmland: drawFarmlandHatch,
|
||||||
|
settlement: drawSettlementSymbol,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Linear feature rendering ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route linear features through the hex with paired bezier curves.
|
||||||
|
* Edges are paired: if 2 edges, one curve from A to B through center.
|
||||||
|
* If odd number, last edge is a dead-end to center.
|
||||||
|
*/
|
||||||
|
function drawLinearFeature(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
geom: HexGeometry,
|
||||||
|
terrainId: string,
|
||||||
|
edges: HexEdge[],
|
||||||
|
color: string,
|
||||||
|
size: number,
|
||||||
|
): void {
|
||||||
|
if (edges.length === 0) return;
|
||||||
|
|
||||||
|
const { cx, cy, edgeMidpoints, vertices } = geom;
|
||||||
|
|
||||||
|
if (terrainId === 'coastline') {
|
||||||
|
drawCoastline(ctx, vertices, edges, color, size);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRoad = terrainId === 'road';
|
||||||
|
const isRiver = terrainId === 'river';
|
||||||
|
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = isRiver ? Math.max(2, size / 6) : Math.max(1, size / 8);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
if (isRoad) {
|
||||||
|
ctx.setLineDash([size / 4, size / 6]);
|
||||||
|
} else {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair edges for routing
|
||||||
|
const pairs = pairEdges(edges);
|
||||||
|
|
||||||
|
for (const pair of pairs) {
|
||||||
|
if (pair.length === 2) {
|
||||||
|
// Two edges: smooth bezier through center
|
||||||
|
const p1 = edgeMidpoints[pair[0]];
|
||||||
|
const p2 = edgeMidpoints[pair[1]];
|
||||||
|
drawBezierRoute(ctx, p1, p2, cx, cy, size, isRiver);
|
||||||
|
} else {
|
||||||
|
// Dead-end: edge midpoint to center
|
||||||
|
const mp = edgeMidpoints[pair[0]];
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(mp.x, mp.y);
|
||||||
|
ctx.lineTo(cx, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
// Terminus dot
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, Math.max(1.5, size / 10), 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
if (isRoad) ctx.setLineDash([size / 4, size / 6]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair edges into routes. Try to pair opposite or near-opposite edges first.
|
||||||
|
* Returns array of pairs (2-element arrays) and singletons (dead-ends).
|
||||||
|
*/
|
||||||
|
function pairEdges(edges: HexEdge[]): HexEdge[][] {
|
||||||
|
if (edges.length === 0) return [];
|
||||||
|
if (edges.length === 1) return [[edges[0]]];
|
||||||
|
|
||||||
|
const remaining = new Set(edges);
|
||||||
|
const pairs: HexEdge[][] = [];
|
||||||
|
|
||||||
|
// Try to pair opposite edges first (straight-through)
|
||||||
|
const opposites: [HexEdge, HexEdge][] = [
|
||||||
|
[HexEdge.NE, HexEdge.SW],
|
||||||
|
[HexEdge.E, HexEdge.W],
|
||||||
|
[HexEdge.SE, HexEdge.NW],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [a, b] of opposites) {
|
||||||
|
if (remaining.has(a) && remaining.has(b)) {
|
||||||
|
pairs.push([a, b]);
|
||||||
|
remaining.delete(a);
|
||||||
|
remaining.delete(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair remaining edges by proximity (adjacent pairs make tight bends)
|
||||||
|
const rest = Array.from(remaining);
|
||||||
|
while (rest.length >= 2) {
|
||||||
|
pairs.push([rest.shift()!, rest.shift()!]);
|
||||||
|
}
|
||||||
|
if (rest.length === 1) {
|
||||||
|
pairs.push([rest[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBezierRoute(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
p1: PixelCoord,
|
||||||
|
p2: PixelCoord,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
size: number,
|
||||||
|
wobble: boolean,
|
||||||
|
): void {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p1.x, p1.y);
|
||||||
|
|
||||||
|
if (wobble) {
|
||||||
|
// River: slight sinusoidal wobble via cubic bezier
|
||||||
|
const mx = (p1.x + p2.x) / 2;
|
||||||
|
const my = (p1.y + p2.y) / 2;
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const perpX = -dy * 0.15;
|
||||||
|
const perpY = dx * 0.15;
|
||||||
|
|
||||||
|
ctx.bezierCurveTo(
|
||||||
|
(p1.x + cx) / 2 + perpX, (p1.y + cy) / 2 + perpY,
|
||||||
|
(p2.x + cx) / 2 - perpX, (p2.y + cy) / 2 - perpY,
|
||||||
|
p2.x, p2.y,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Standard: quadratic through center
|
||||||
|
ctx.quadraticCurveTo(cx, cy, p2.x, p2.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCoastline(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
vertices: PixelCoord[],
|
||||||
|
edges: HexEdge[],
|
||||||
|
color: string,
|
||||||
|
size: number,
|
||||||
|
): void {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = Math.max(2, size / 5);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Coastline runs along hex edges between vertices
|
||||||
|
// Edge i runs from vertex i to vertex (i+1)%6
|
||||||
|
// But our edges are reordered — we need the vertex indices for each HexEdge
|
||||||
|
// HexEdge NE(0)=vertex pair [5,0], E(1)=[0,1], SE(2)=[1,2],
|
||||||
|
// SW(3)=[2,3], W(4)=[3,4], NW(5)=[4,5]
|
||||||
|
const edgeToVertices: Record<HexEdge, [number, number]> = {
|
||||||
|
[HexEdge.NE]: [5, 0],
|
||||||
|
[HexEdge.E]: [0, 1],
|
||||||
|
[HexEdge.SE]: [1, 2],
|
||||||
|
[HexEdge.SW]: [2, 3],
|
||||||
|
[HexEdge.W]: [3, 4],
|
||||||
|
[HexEdge.NW]: [4, 5],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const [vi1, vi2] = edgeToVertices[edge];
|
||||||
|
const v1 = vertices[vi1];
|
||||||
|
const v2 = vertices[vi2];
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(v1.x, v1.y);
|
||||||
|
ctx.lineTo(v2.x, v2.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main render function ---
|
||||||
|
|
||||||
|
export function renderHex(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
geom: HexGeometry,
|
||||||
|
terrain: HexTerrain,
|
||||||
|
options: {
|
||||||
|
opacity: number;
|
||||||
|
showGrid: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const { size } = geom;
|
||||||
|
if (size < 2) return;
|
||||||
|
|
||||||
|
// 1. Area fill
|
||||||
|
const baseType = getTerrainType(terrain.base);
|
||||||
|
if (baseType) {
|
||||||
|
clipToHex(ctx, geom);
|
||||||
|
ctx.globalAlpha = options.opacity;
|
||||||
|
ctx.fillStyle = baseType.color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
// 2. Area texture pattern
|
||||||
|
const textureRenderer = TEXTURE_RENDERERS[terrain.base];
|
||||||
|
if (textureRenderer && size > 10) {
|
||||||
|
ctx.globalAlpha = options.opacity;
|
||||||
|
textureRenderer(ctx, geom, baseType.color);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Linear features (sorted by zIndex)
|
||||||
|
const sortedFeatures = [...terrain.features].sort((a, b) => {
|
||||||
|
const ta = getTerrainType(a.terrainId);
|
||||||
|
const tb = getTerrainType(b.terrainId);
|
||||||
|
return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const feature of sortedFeatures) {
|
||||||
|
const type = getTerrainType(feature.terrainId);
|
||||||
|
if (!type) continue;
|
||||||
|
const edges = connectedEdges(feature.edgeMask);
|
||||||
|
if (edges.length === 0) continue;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.9;
|
||||||
|
drawLinearFeature(ctx, geom, feature.terrainId, edges, type.color, size);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Grid outline
|
||||||
|
if (options.showGrid && size > 4) {
|
||||||
|
drawHexOutline(ctx, geom, 'rgba(0,0,0,0.25)', 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Selection highlight
|
||||||
|
if (options.selected) {
|
||||||
|
drawHexOutline(ctx, geom, '#fff', Math.max(1, size / 10));
|
||||||
|
drawHexOutline(ctx, geom, '#000', Math.max(0.5, size / 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function clipToHex(ctx: CanvasRenderingContext2D, geom: HexGeometry): void {
|
||||||
|
ctx.beginPath();
|
||||||
|
const { vertices } = geom;
|
||||||
|
ctx.moveTo(vertices[0].x, vertices[0].y);
|
||||||
|
for (let i = 1; i < 6; i++) {
|
||||||
|
ctx.lineTo(vertices[i].x, vertices[i].y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHexOutline(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
geom: HexGeometry,
|
||||||
|
color: string,
|
||||||
|
lineWidth: number,
|
||||||
|
): void {
|
||||||
|
const { vertices } = geom;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(vertices[0].x, vertices[0].y);
|
||||||
|
for (let i = 1; i < 6; i++) {
|
||||||
|
ctx.lineTo(vertices[i].x, vertices[i].y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lighten(hex: string, percent: number): string {
|
||||||
|
return adjustColor(hex, percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function darken(hex: string, percent: number): string {
|
||||||
|
return adjustColor(hex, -percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustColor(hex: string, percent: number): string {
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const r = Math.min(255, Math.max(0, ((num >> 16) & 0xff) + Math.round(2.55 * percent)));
|
||||||
|
const g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + Math.round(2.55 * percent)));
|
||||||
|
const b = Math.min(255, Math.max(0, (num & 0xff) + Math.round(2.55 * percent)));
|
||||||
|
return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
allowedHosts: ['hexifyer.davoryn.de'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
|
|||||||
Reference in New Issue
Block a user