BareGit
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("<img src='" + dataURL + "' alt='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);
    });
}