From 5f168246cca42ba54b67bd84fbd69fd6a5b9f820 Mon Sep 17 00:00:00 2001 From: MetroWind Date: Thu, 25 Sep 2025 16:16:58 -0700 Subject: This repo will be used for all small web apps. The NMS Freighter Planner is moved into its own dir. --- nms-freighter-planner/scripts/main.js | 256 ++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 nms-freighter-planner/scripts/main.js (limited to 'nms-freighter-planner/scripts/main.js') diff --git a/nms-freighter-planner/scripts/main.js b/nms-freighter-planner/scripts/main.js new file mode 100644 index 0000000..d6b6787 --- /dev/null +++ b/nms-freighter-planner/scripts/main.js @@ -0,0 +1,256 @@ +const DEFAULT_APP_STATE = { + floor: 0, // This is 0-based. + plan: new Array(FLOOR_COUNT).fill(null).map(() => + new Array(GRID_SIZE_X * GRID_SIZE_Y).fill(0)), + // mouse_state can be “normal”, or “dnd”. + mouse_state: "normal", + dragged_asset_index: null, + plan_code: "", +}; + +async function serializePlan(app_state) +{ + return compressBytes(Uint8Array.from(app_state.plan.flat())); +} + +function genGrid(x_count, y_count, cell_size) +{ + let lines = []; + for(let x = 0; x < x_count+1; x++) + { + lines.push(h("line", { + "x1": x * cell_size + 0.5, "x2": x * cell_size + 0.5, "y1": 0.5, + "y2": y_count * cell_size + 0.5, + })); + } + for(let y = 0; y < y_count+1; y++) + { + lines.push(h("line", { + "y1": y * cell_size + 0.5, "y2": y * cell_size + 0.5, "x1": 0.5, + "x2": x_count * cell_size + 0.5, + })); + } + return h("g", {"stroke": "black", "stroke-width": 1}, lines); +} + +function AssetView({asset_index, on_drag_begin, on_drag_end}) +{ + let bg = ASSET_WHITE_BG; + if(TILES[asset_index].bg === null) + { + bg = null; + } + return h("div", {"class": "Asset", "draggable": true, + "data-asset-index": asset_index, + "ondragstart": on_drag_begin, + "ondragend": on_drag_end, + "title": TILES[asset_index].name,}, + h("svg", ASSET_SVG_PROPS, bg, TILES[asset_index].inner_svg)); +} + +function AssetsView({on_drag_begin, on_drag_end}) +{ + let views = TILES.map((t, i) => + h(AssetView, {asset_index: i, on_drag_begin: on_drag_begin, + on_drag_end: on_drag_end})); + return h("div", {}, views); +} + +function PlanGridView({content, mouse_state, on_drop_asset}) +{ + const [mouse_coord, setMouseCoord] = preactHooks.useState(null); + + function onDragOver(e) + { + e.preventDefault(); + let b = e.target.getBoundingClientRect(); + let x = e.clientX - b.left; + let y = e.clientY - b.top; + setMouseCoord([x, y]); + } + + function onDragLeave(e) + { + setMouseCoord(null); + } + + let hilight_box = null; + + if(mouse_coord !== null && mouse_state != "dnd") + { + setMouseCoord(null); + } + + if(mouse_coord !== null && mouse_state == "dnd") + { + let cell_x = Math.floor(mouse_coord[0] / (CELL_SIZE + 1)); + let cell_y = Math.floor(mouse_coord[1] / (CELL_SIZE + 1)); + hilight_box = h("rect", { + "x": cell_x * (CELL_SIZE + 1) + 0.5, "y": cell_y * (CELL_SIZE + 1) + 0.5, + "width": CELL_SIZE + 1, "height": CELL_SIZE + 1, + "stroke": "#2ed573", "stroke-width": 3, "fill": "transparent"}); + } + let grid_content = content.map((idx, i) => { + if(idx == 0) return null; + let cell_x = i % GRID_SIZE_X; + let cell_y = Math.floor(i / GRID_SIZE_Y); + let bg = ASSET_WHITE_BG; + if(TILES[idx].bg === null) + { + bg = null; + } + return h("g", {"transform": `translate(${cell_x * (CELL_SIZE + 1) + 1} \ +${cell_y * (CELL_SIZE + 1) + 1})`}, bg, TILES[idx].inner_svg); + }); + + let img_width = GRID_SIZE_X * (CELL_SIZE + 1) + 1; + let img_height = GRID_SIZE_Y * (CELL_SIZE + 1) + 1; + return h("svg", {"width": img_width, "height": img_height, + "version": "1.1", "ondragover": onDragOver, + "ondrop": on_drop_asset, + "id": "Grid", "ondragleave": onDragLeave,}, + h("rect", {x: 0, y: 0, width: img_width, height: img_height, + fill: "#c0c0c0", "stroke-width": 0}), + grid_content, genGrid(GRID_SIZE_X, GRID_SIZE_Y, CELL_SIZE + 1), + hilight_box); +} + +function PlanView({plan, mouse_state, on_drop_asset}) +{ + return h("div", {"id": "PlanView"}, + h("div", {"id": "EntryIndicator"}, "⬇️"), + h(PlanGridView, {content: plan, mouse_state: mouse_state, + on_drop_asset: on_drop_asset})); +} + +// on_floor_change takes one argument, which is the 0-based floor number. +function FloorSelector({floor, on_floor_change}) +{ + return h("div", {"id": "FloorSelectorWrapper", "class": "ButtonRowLeft"}, + h("label", {}, "Floor"), + h("input", {"id": "InputFloor", "type": "number", "step": 1, "min": 1, + "max": 15, "value": floor, + onchange: e => on_floor_change(e.target.value - 1),},)); +} + +function App({initial_state}) +{ + const [state, setState] = preactHooks.useState(initial_state); + + // This event is targeted at the dragged asset. + function onDragAssetBegin(e) + { + let new_state = structuredClone(state); + new_state.mouse_state = "dnd"; + new_state.dragged_asset_index = + parseInt(e.target.getAttribute("data-asset-index")); + setState(new_state); + } + + // This event is targeted at the grid. + function onDropAssetOnGrid(e) + { + if(e.dragEffect === "none") return; + let grid = document.getElementById("Grid"); + let b = grid.getBoundingClientRect(); + let x = e.clientX - b.left; + let y = e.clientY - b.top; + let cell_x = Math.floor(x / (CELL_SIZE + 1)); + let cell_y = Math.floor(y / (CELL_SIZE + 1)); + let cell_index = cell_y * GRID_SIZE_X + cell_x; + let new_state = structuredClone(state); + new_state.plan[new_state.floor][cell_index] = state.dragged_asset_index; + new_state.mouse_state = "normal"; + new_state.dragged_asset_index = null; + serializePlan(new_state).then((s) => { + new_state.plan_code = s; + setState(new_state); + const url = new URL(location); + url.searchParams.set("plan", s); + window.history.pushState({}, "", url); + }); + } + + function onClickSaveImg() + { + let grid_width = GRID_SIZE_X * (CELL_SIZE + 1) + 1; + let grid_height = GRID_SIZE_Y * (CELL_SIZE + 1) + 1; + let canvas = document.createElement('canvas'); + canvas.width = grid_width; + canvas.height = grid_height; + let context = canvas.getContext("2d"); + var svg_str = new XMLSerializer().serializeToString( + document.getElementById("Grid")); + var img = new Image(); + img.onload = function() + { + context.drawImage(this, 0, 0); + // Open PNG image in new tab. + var dataURL = canvas.toDataURL("image/png"); + var new_tab = window.open('about:blank', 'image from canvas'); + new_tab.document.write("from canvas"); + } + img.src = 'data:image/svg+xml; charset=utf8, ' + + encodeURIComponent(svg_str); + } + + // new_floor is 0-based. + function onFloorChange(new_floor) + { + let new_state = structuredClone(state); + new_state.floor = new_floor; + setState(new_state); + } + + return h("div", {}, + h("div", {"class": "MainWrapper"}, + h("aside", {}, + h(FloorSelector, {floor: state.floor + 1, + on_floor_change: onFloorChange}), + h("div", {"class": "LabeledPanel"}, + h("h2", {}, "Rooms"), + h(AssetsView, {on_drag_begin: onDragAssetBegin,}))), + h(PlanView, {plan: state.plan[state.floor], + mouse_state: state.mouse_state, + on_drop_asset: onDropAssetOnGrid,})), + h("hr", {}), + h("div", {}, + h("textarea", {readonly: true}, state.plan_code)), + h("div", {"class": "ButtonRow"}, + h("button", {onclick: onClickSaveImg, type: "button"}, + "Open as Image!")), + ); +} + +function render(app_state) +{ + preact.render(h(App, {initial_state: app_state}), + document.getElementById("Plan")); +} + +const paramsString = window.location.search; +const searchParams = new URLSearchParams(paramsString); +const encoded_plan = searchParams.get("plan"); +if(encoded_plan === null) +{ + render(DEFAULT_APP_STATE); +} +else +{ + decompressBytes(encoded_plan).then(a => { + let state = structuredClone(DEFAULT_APP_STATE); + let concated_plan = Array.from(a); + console.debug(concated_plan); + state.plan = []; + for(let i = 0; i < GRID_SIZE_X * GRID_SIZE_Y * FLOOR_COUNT; + i += GRID_SIZE_X * GRID_SIZE_Y) + { + let floor_slice = + concated_plan.slice(i, i + GRID_SIZE_X * GRID_SIZE_Y); + console.debug(floor_slice); + state.plan.push(floor_slice); + } + state.plan_code = encoded_plan; + render(state); + }); +} -- cgit v1.2.3-70-g09d2