-
-
Notifications
You must be signed in to change notification settings - Fork 893
Jagged coastline #1392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Jagged coastline #1392
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
fbd89df
feat: add coastline fractalization
Azgaar f0d6541
refactor: streamline coastline settings and improve feature path rend…
Azgaar 161ed40
feat: implement coastline settings dialog and preview functionality
Azgaar d4e4535
refactor: update coastline fractalization and rendering logic
Azgaar f216e55
refactor: enhance coastline roughness graph rendering and improve sha…
Azgaar d300d5e
refactor: adjust base amplitude and refine Hermite control points for…
Azgaar d3ff84c
refactor: improve code formatting and consistency in coastline render…
Azgaar 975747a
refactor: streamline button attributes and enhance coastline fractali…
Azgaar 3003066
refactor: specify return type for buildCoastlinePath function and opt…
Azgaar a6ed4eb
refactor: adjust stroke width for sea island in coastline rendering
Azgaar d092da0
refactor: don't add fractal on edges
Azgaar 65c99d5
refactor: add coastline settings editor and update hotkeys
Azgaar 937d5c1
refactor: migrate coastline settings editor to TypeScript and update …
Azgaar 28c758d
refactor: enhance coastline settings with new presets and update frac…
Azgaar c4ff97f
refactor: add coastline editor script to index.html
Azgaar a40a39d
refactor: update latest public changes for jagged coastlines and heig…
Azgaar a78f332
refactor: add new supporters to the supporters list
Azgaar 78a3a1b
refactor: clean up code formatting and remove unnecessary whitespace
Azgaar f2548c6
refactor: simplify coastline HTML structure by removing unnecessary l…
Azgaar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,364 @@ | ||
| import Alea from "alea"; | ||
| import { | ||
| buildCoastlinePath, | ||
| type CoastlineSettings, | ||
| coastSettings, | ||
| fractalize, | ||
| makeRoughnessProfile, | ||
| PROFILE_SIZE, | ||
| } from "../renderers/coastline-fractal"; | ||
|
|
||
| declare global { | ||
| var showCoastlineSettings: () => void; | ||
| } | ||
|
|
||
| const PREVIEW_SEED = "preview_coastline_42"; | ||
|
|
||
| interface SliderDef { | ||
| id: string; | ||
| label: string; | ||
| tip: string; | ||
| min: number; | ||
| max: number; | ||
| step: number; | ||
| key: keyof CoastlineSettings; | ||
| } | ||
|
|
||
| const SLIDER_DEFS: SliderDef[] = [ | ||
| { | ||
| id: "coastMaxDepth", | ||
| label: "Detail depth", | ||
| tip: "Maximum recursion levels per edge. Each +1 can double point count in rough zones.", | ||
| min: 1, | ||
| max: 5, | ||
| step: 1, | ||
| key: "maxDepth", | ||
| }, | ||
| { | ||
| id: "coastBaseAmplitude", | ||
| label: "Roughness amplitude", | ||
| tip: "Peak perpendicular displacement. Scales with √(edge length) so large edges stay proportional.", | ||
| min: 0.2, | ||
| max: 4, | ||
| step: 0.1, | ||
| key: "baseAmplitude", | ||
| }, | ||
| { | ||
| id: "coastAmplitudeDecay", | ||
| label: "Amplitude decay", | ||
| tip: "Amplitude multiplier per recursion level (Hurst exponent). Lower = more jagged finer detail.", | ||
| min: 0.01, | ||
| max: 0.99, | ||
| step: 0.01, | ||
| key: "amplitudeDecay", | ||
| }, | ||
| { | ||
| id: "coastMinEdge", | ||
| label: "Minimum edge", | ||
| tip: "Edges shorter than this (map units) are never subdivided regardless of roughness.", | ||
| min: 0.1, | ||
| max: 10, | ||
| step: 0.1, | ||
| key: "minEdge", | ||
| }, | ||
| { | ||
| id: "coastSmoothThreshold", | ||
| label: "Smooth threshold", | ||
| tip: "Profile values below this receive zero displacement → glassy arc. Controls calm-coast coverage.", | ||
| min: 0.01, | ||
| max: 0.5, | ||
| step: 0.01, | ||
| key: "smoothThreshold", | ||
| }, | ||
| { | ||
| id: "coastRoughnessContrast", | ||
| label: "Roughness contrast", | ||
| tip: "Power applied to the roughness profile. Higher = sharper calm/rough transition.", | ||
| min: 0.5, | ||
| max: 10, | ||
| step: 0.1, | ||
| key: "roughnessContrast", | ||
| }, | ||
| ]; | ||
|
|
||
| function drawRoughnessGraph(canvas: HTMLCanvasElement): void { | ||
| const W = canvas.width; | ||
| const H = canvas.height; | ||
| const ctx = canvas.getContext("2d")!; | ||
| ctx.clearRect(0, 0, W, H); | ||
|
|
||
| const rand = Alea(PREVIEW_SEED); | ||
| const profile = makeRoughnessProfile(rand, coastSettings.roughnessContrast); | ||
|
|
||
| const pl = 2, | ||
| pr = 2, | ||
| pt = 6, | ||
| pb = 6; | ||
| const gW = W - pl - pr; | ||
| const gH = H - pt - pb; | ||
| const thresh = Math.min(Math.max(coastSettings.smoothThreshold, 0), 1); | ||
| const threshY = pt + gH * (1 - thresh); | ||
| const baseY = pt + gH; | ||
|
|
||
| // Pre-compute curve points | ||
| const xs: number[] = []; | ||
| const ys: number[] = []; | ||
| for (let i = 0; i <= PROFILE_SIZE; i++) { | ||
| xs.push(pl + (i / PROFILE_SIZE) * gW); | ||
| ys.push(pt + gH * (1 - profile[i % PROFILE_SIZE])); | ||
| } | ||
|
|
||
| // Helper: fill area under curve clipped to a horizontal band | ||
| const fillBand = (clipTop: number, clipBot: number, color: string): void => { | ||
| const h = clipBot - clipTop; | ||
| if (h <= 0) return; | ||
| ctx.save(); | ||
| ctx.beginPath(); | ||
| ctx.rect(pl, clipTop, gW, h); | ||
| ctx.clip(); | ||
| ctx.beginPath(); | ||
| ctx.moveTo(xs[0], ys[0]); | ||
| for (let i = 1; i < xs.length; i++) ctx.lineTo(xs[i], ys[i]); | ||
| ctx.lineTo(xs[xs.length - 1], baseY); | ||
| ctx.lineTo(xs[0], baseY); | ||
| ctx.closePath(); | ||
| ctx.fillStyle = color; | ||
| ctx.fill(); | ||
| ctx.restore(); | ||
| }; | ||
|
|
||
| // Helper: stroke curve clipped to a horizontal band | ||
| const strokeBand = ( | ||
| clipTop: number, | ||
| clipBot: number, | ||
| color: string, | ||
| ): void => { | ||
| const h = clipBot - clipTop; | ||
| if (h <= 0) return; | ||
| ctx.save(); | ||
| ctx.beginPath(); | ||
| ctx.rect(pl, clipTop, gW, h); | ||
| ctx.clip(); | ||
| ctx.beginPath(); | ||
| ctx.moveTo(xs[0], ys[0]); | ||
| for (let i = 1; i < xs.length; i++) ctx.lineTo(xs[i], ys[i]); | ||
| ctx.strokeStyle = color; | ||
| ctx.lineWidth = 1.5; | ||
| ctx.stroke(); | ||
| ctx.restore(); | ||
| }; | ||
|
|
||
| // Rough zone (above threshold): warm orange | ||
| fillBand(pt, threshY, "rgba(210,90,30,0.20)"); | ||
| strokeBand(pt, threshY, "#c85520"); | ||
|
|
||
| // Smooth zone (below threshold): cool teal | ||
| fillBand(threshY, baseY, "rgba(30,165,135,0.20)"); | ||
| strokeBand(threshY, baseY, "#18a888"); | ||
|
|
||
| // Threshold dashed line | ||
| ctx.save(); | ||
| ctx.beginPath(); | ||
| ctx.setLineDash([4, 3]); | ||
| ctx.moveTo(pl, threshY); | ||
| ctx.lineTo(W - pr, threshY); | ||
| ctx.strokeStyle = "rgba(30,140,100,0.75)"; | ||
| ctx.lineWidth = 1; | ||
| ctx.stroke(); | ||
| ctx.setLineDash([]); | ||
| ctx.restore(); | ||
|
|
||
| // Zone labels | ||
| ctx.font = "bold 8px sans-serif"; | ||
| ctx.textAlign = "left"; | ||
| if (threshY > pt + 12) { | ||
| ctx.fillStyle = "#c85520"; | ||
| ctx.fillText("ROUGH", pl + 3, pt + 9); | ||
| } | ||
| if (baseY - threshY > 10) { | ||
| ctx.fillStyle = "#18a888"; | ||
| ctx.fillText("CALM", pl + 3, baseY - 2); | ||
| } | ||
| } | ||
|
|
||
| function drawShapePreview(canvas: HTMLCanvasElement): void { | ||
| const W = canvas.width; | ||
| const H = canvas.height; | ||
| const ctx = canvas.getContext("2d")!; | ||
| ctx.clearRect(0, 0, W, H); | ||
|
|
||
| const cx = W / 2; | ||
| const cy = H / 2; | ||
| const r = Math.min(W, H) * 0.34; | ||
|
|
||
| // Generate at canvas scale so all setting changes are immediately visible. | ||
| const basePts: [number, number][] = [ | ||
| [cx, cy - r], // top | ||
| [cx + r, cy], // right | ||
| [cx, cy + r], // bottom | ||
| [cx - r, cy], // left | ||
| ]; | ||
|
|
||
| const shape = fractalize(basePts, Alea(PREVIEW_SEED), coastSettings); | ||
| const path = new Path2D(`${buildCoastlinePath(shape)}Z`); | ||
|
|
||
| // Ocean background — radial gradient, lighter at centre | ||
| const bgGrad = ctx.createRadialGradient( | ||
| cx, | ||
| cy, | ||
| 0, | ||
| cx, | ||
| cy, | ||
| Math.max(W, H) * 0.85, | ||
| ); | ||
| bgGrad.addColorStop(0, "#cce5f5"); | ||
| bgGrad.addColorStop(1, "#6aa4cb"); | ||
| ctx.fillStyle = bgGrad; | ||
| ctx.fillRect(0, 0, W, H); | ||
|
|
||
| // Land fill with drop shadow | ||
| const landGrad = ctx.createRadialGradient( | ||
| cx - r * 0.1, | ||
| cy - r * 0.1, | ||
| r * 0.05, | ||
| cx, | ||
| cy, | ||
| r * 1.1, | ||
| ); | ||
| landGrad.addColorStop(0, "#d8c87a"); | ||
| landGrad.addColorStop(0.5, "#9cbc60"); | ||
| landGrad.addColorStop(1, "#5c8e40"); | ||
|
|
||
| ctx.save(); | ||
| ctx.shadowColor = "rgba(0,20,60,0.35)"; | ||
| ctx.shadowBlur = 8; | ||
| ctx.shadowOffsetX = 3; | ||
| ctx.shadowOffsetY = 3; | ||
| ctx.fillStyle = landGrad; | ||
| ctx.fill(path); | ||
| ctx.restore(); | ||
|
|
||
| // Coastline stroke | ||
| ctx.strokeStyle = "#5c4526"; | ||
| ctx.lineWidth = 1.5; | ||
| ctx.stroke(path); | ||
|
|
||
| // Original polygon skeleton — shows the raw 4-vertex input before fractalization | ||
| const origPts = shape.origIndices.map((i) => shape.points[i]); | ||
| ctx.beginPath(); | ||
| for (let j = 0; j < origPts.length; j++) { | ||
| const [x, y] = origPts[j]; | ||
| j === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | ||
| } | ||
| ctx.closePath(); | ||
| ctx.strokeStyle = "rgba(255,255,255,0.45)"; | ||
| ctx.lineWidth = 0.8; | ||
| ctx.setLineDash([3, 3]); | ||
| ctx.stroke(); | ||
| ctx.setLineDash([]); | ||
|
|
||
| // Original vertex dots | ||
| for (const [x, y] of origPts) { | ||
| ctx.beginPath(); | ||
| ctx.arc(x, y, 2.5, 0, Math.PI * 2); | ||
| ctx.fillStyle = "rgba(255,255,255,0.85)"; | ||
| ctx.fill(); | ||
| ctx.strokeStyle = "rgba(60,40,10,0.55)"; | ||
| ctx.lineWidth = 0.8; | ||
| ctx.stroke(); | ||
| } | ||
| } | ||
|
|
||
| function updatePreviews(): void { | ||
| const graph = document.getElementById("coastRoughnessGraph"); | ||
| const shape = document.getElementById("coastShapePreview"); | ||
| if (graph) drawRoughnessGraph(graph as HTMLCanvasElement); | ||
| if (shape) drawShapePreview(shape as HTMLCanvasElement); | ||
| } | ||
|
|
||
| function buildDialogHTML(): string { | ||
| const rows = SLIDER_DEFS.map(({ id, label, tip, min, max, step, key }) => { | ||
| const val = coastSettings[key] as number; | ||
| return /* html */ ` | ||
| <tr data-tip="${tip}"> | ||
| <td style="padding:4px 8px;white-space:nowrap">${label}</td> | ||
| <td style="padding:4px 4px"> | ||
| <input id="${id}" type="range" min="${min}" max="${max}" step="${step}" value="${val}" | ||
| style="width:160px;vertical-align:middle"/> | ||
| </td> | ||
| <td style="padding:4px 6px;min-width:2.8em;text-align:right"> | ||
| <span id="${id}Out" style="font-family:monospace;font-size:.85em">${val}</span> | ||
| </td> | ||
| <td style="padding:4px 4px"> | ||
| <button id="${id}Reset" title="Reset to default" | ||
| style="font-size:.75em;padding:1px 5px;cursor:pointer">↺</button> | ||
| </td> | ||
| </tr>`; | ||
| }).join(""); | ||
|
|
||
| return /* html */ ` | ||
| <div id="coastlineSettingsDialog" style="display:none"> | ||
| <table style="border-collapse:collapse;width:100%"> | ||
| <tbody>${rows}</tbody> | ||
| </table> | ||
| <div style="display:flex;gap:6px;margin-top:10px;align-items:flex-start"> | ||
| <div style="flex:1;min-width:0"> | ||
| <div style="font-size:.72em;color:#999;margin-bottom:3px">Roughness profile</div> | ||
| <canvas id="coastRoughnessGraph" width="266" height="100" | ||
| style="border:1px solid #ccc;border-radius:2px;display:block"></canvas> | ||
| </div> | ||
| <div> | ||
| <div style="font-size:.72em;color:#999;margin-bottom:3px">Shape preview</div> | ||
| <canvas id="coastShapePreview" width="100" height="100" | ||
| style="border:1px solid #ccc;border-radius:2px;display:block"></canvas> | ||
| </div> | ||
| </div> | ||
| </div>`; | ||
| } | ||
|
|
||
| function setupCoastlineEditor(): void { | ||
| if (!document.getElementById("coastlineSettingsDialog")) { | ||
| document.body.insertAdjacentHTML("beforeend", buildDialogHTML()); | ||
| } | ||
|
|
||
| for (const { id, key } of SLIDER_DEFS) { | ||
| const slider = document.getElementById(id) as HTMLInputElement | null; | ||
| const output = document.getElementById(`${id}Out`) as HTMLElement | null; | ||
| const resetBtn = document.getElementById( | ||
| `${id}Reset`, | ||
| ) as HTMLElement | null; | ||
|
|
||
| if (!slider || !output || !resetBtn) continue; | ||
|
|
||
| const defaultVal = coastSettings[key] as number; | ||
|
|
||
| slider.addEventListener("input", () => { | ||
| const val = parseFloat(slider.value); | ||
| (coastSettings[key] as number) = val; | ||
| output.textContent = String(val); | ||
| updatePreviews(); | ||
| if (typeof drawFeatures === "function") drawFeatures(); | ||
| }); | ||
|
|
||
| resetBtn.addEventListener("click", () => { | ||
| (coastSettings[key] as number) = defaultVal; | ||
| slider.value = String(defaultVal); | ||
| output.textContent = String(defaultVal); | ||
| updatePreviews(); | ||
| if (typeof drawFeatures === "function") drawFeatures(); | ||
| }); | ||
| } | ||
|
|
||
| updatePreviews(); | ||
|
|
||
| window.showCoastlineSettings = () => { | ||
| $("#coastlineSettingsDialog").dialog({ | ||
| title: "Coastline Advanced Settings", | ||
| resizable: false, | ||
| width: "auto", | ||
| position: { my: "center", at: "center", of: "svg" }, | ||
| }); | ||
| }; | ||
| } | ||
|
|
||
| setupCoastlineEditor(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.