From 81b539d6a63dd4921686fb50f05daf1b1d725e3b Mon Sep 17 00:00:00 2001 From: MetroWind Date: Sat, 27 Sep 2025 13:05:27 -0700 Subject: Add button maker. Support basic editing. --- button-maker/scripts/lib.js | 79 ++++++++++++ button-maker/scripts/main.js | 279 ++++++++++++++++++++++++++++++++++++++++++ button-maker/scripts/tiles.js | 94 ++++++++++++++ 3 files changed, 452 insertions(+) create mode 100644 button-maker/scripts/lib.js create mode 100644 button-maker/scripts/main.js create mode 100644 button-maker/scripts/tiles.js (limited to 'button-maker/scripts') diff --git a/button-maker/scripts/lib.js b/button-maker/scripts/lib.js new file mode 100644 index 0000000..372d9d7 --- /dev/null +++ b/button-maker/scripts/lib.js @@ -0,0 +1,79 @@ +// In Firefox (on Mac only?), for , setting value +// to abbreviated hex form (e.g. #123) doesn’t work. So all colors +// should be specified in full form. +const DEFAULT_NETSCAPE_PARAMS = { + color_bg: "#c0c0c0", + logo_url: "https://upload.wikimedia.org/wikipedia/commons/0/08/Netscape_icon.svg", + text_top: { + content: "Netscape", + pos: [31, 11], + font_size: 10.5, + color: "#000000", + }, + text_bottom: { + content: "Now!", + pos: [30, 28], + font_size: 17.5, + color: "#ff0000", + }, + banner: { + style: "corner_bottom_right", // corner_bottom_right or right + color: "#008080", + text: { + content: "5.0", + pos: [65, 46], + font_size: 8, + color: "#ffffff", + }, + }, +}; + +const DEFAULT_APP_STATE = { + button_type: "netscape", + button_params: DEFAULT_NETSCAPE_PARAMS, +} + +const SVG_ATTRIBUTES = {viewBox: "0 0 88 31", width: 88, height: 31}; + +// Return three integers as an array. +function parseHexColor(color_str) +{ + console.debug("Parsing", color_str); + m = color_str.match(/^#([0-9a-f]{3})$/i); + if(m) + { + // in three-character format, each value is multiplied by 0x11 to give an + // even scale from 0x00 to 0xff + return [ + parseInt(m[1].charAt(0),16)*0x11, + parseInt(m[1].charAt(1),16)*0x11, + parseInt(m[1].charAt(2),16)*0x11 + ]; + } + + m = color_str.match(/^#([0-9a-f]{6})$/i); + if(m) + { + return [ + parseInt(m[1].substr(0,2),16), + parseInt(m[1].substr(2,2),16), + parseInt(m[1].substr(4,2),16) + ]; + } + return null; +} + +function lighten(rgb, delta) +{ + return [rgb[0] + delta, rgb[1] + delta, rgb[2] + delta]; +} + +function darken(rgb, delta) +{ + return [rgb[0] - delta, rgb[1] - delta, rgb[2] - delta]; +} + +function color2Str(color) +{ + return `rgb(${color[0]} ${color[1]} ${color[2]})`; +} diff --git a/button-maker/scripts/main.js b/button-maker/scripts/main.js new file mode 100644 index 0000000..7232064 --- /dev/null +++ b/button-maker/scripts/main.js @@ -0,0 +1,279 @@ +const h = preact.h; + +function ButtonViewNetscape({app_state}) +{ + const params = app_state.button_params; + console.debug("Rendering netscape params", params); + let color_bg = parseHexColor(params.color_bg); + let color_hilight_1 = color2Str(lighten(color_bg, 63)); + let color_hilight_2 = color2Str(lighten(color_bg, 31)); + let color_shadow_1 = color2Str(darken(color_bg, 0xc0 - 0xa)); + let color_shadow_2 = color2Str(darken(color_bg, 0xc0 - 0x54)); + + let ribbon = null; + let ribbon_text = null; + if(app_state.button_params.banner.style == "corner_bottom_right") + { + ribbon = h("polygon", {points: "86,8 86,24 81,29 65,29", + fill: params.banner.color}); + ribbon_text = h("text", { + x: params.banner.text.pos[0], y: params.banner.text.pos[1], + "font-family": "sans-serif", + "dominant-baseline": "middle", "text-anchor": "middle", + "transform-origin": "center", "transform":"rotate(-45)", + "font-weight": "bold", "font-size": params.banner.text.font_size, + fill: params.banner.text.color, "stroke-width": 0}, + params.banner.text.content); + } + else if(app_state.button_params.banner.style == "right") + { + ribbon = h("rect", {x: 78, y: 2, width: 8, height: 27, + fill: app_state.button_params.banner.color}); + ribbon_text = h("text", { + x: params.banner.text.pos[0], y: params.banner.text.pos[1], + "font-family": "sans-serif", "dominant-baseline": "middle", + "text-anchor": "middle", "transform-origin": "center", + transform: "rotate(-90)", "font-weight": "bold", + "font-size": params.banner.text.font_size, + fill: params.banner.text.color, "stroke-width": 0}, + params.banner.text.content); + } + + return h("svg", SVG_ATTRIBUTES, + h("g", {"stroke-width": 0}, + h("polygon", {points: "0,0 88,0 87,1 1,1", + fill: color_hilight_1}), + h("polygon", {points: "1,1 87,1 86,2 2,2", + fill: color_hilight_2}), + h("polygon", {points: "88,0 88,31 87,30 87,1", + fill: color_shadow_1}), + h("polygon", {points: "87,1 87,30 86,29 86,2", + fill: color_shadow_2}), + h("polygon", {points: "88,31 0,31 1,30 87,30", + fill: color_shadow_1}), + h("polygon", {points: "87,30 1,30 2,29,86,29", + fill: color_shadow_2}), + h("polygon", {points: "0,0 1,1 1,30 0,31", + fill: color_hilight_1}), + h("polygon", {points: "1,1 2,2 2,29 1,30", + fill: color_hilight_2}), + h("rect", {x: 2, y: 2, width: 84, height: 27, + fill: params.color_bg}), + ribbon), + h("image", {href: params.logo_url, x: 3, y: 3, width: 25, + height: 25}), + h("text", {x: params.text_bottom.pos[0], + y: params.text_bottom.pos[1], "font-family": "Kalam", + "font-size": params.text_bottom.font_size, + fill: params.text_bottom.color, "stroke-width": 0}, + "Now!"), + h("text", {x: params.text_top.pos[0], y: params.text_top.pos[1], + "font-family": "sans-serif", + "font-size": params.text_top.font_size, + fill: params.text_top.color, "stroke-width": 0}, + params.text_top.content), + ribbon_text); +} + +function TextControlView({params, on_change}) +{ + console.debug("Rendering text params", params); + function onContentChange(e) + { + let new_params = structuredClone(params); + new_params.content = e.target.value; + on_change(new_params); + } + + function onPosXChange(e) + { + let new_params = structuredClone(params); + new_params.pos[0] = parseFloat(e.target.value); + on_change(new_params); + } + + function onPosYChange(e) + { + let new_params = structuredClone(params); + new_params.pos[1] = parseFloat(e.target.value); + on_change(new_params); + } + + function onFontSizeChange(e) + { + let new_params = structuredClone(params); + new_params.font_size = parseFloat(e.target.value); + on_change(new_params); + } + + function onColorChange(e) + { + let new_params = structuredClone(params); + new_params.color = e.target.value; + on_change(new_params); + } + + return h(preact.Fragment, {}, + h("div", {"class": "ButtonRowLeft"}, + h("label", {}, "Content"), + h("input", {type: "text", value: params.content, + onchange: onContentChange}), + h("label", {}, "Color"), + h("input", {type: "color", + value: params.color, + onchange: onColorChange})), + h("div", {"class": "ButtonRowLeft"}, + h("label", {}, "x"), + h("input", {type: "number", value: params.pos[0], + onchange: onPosXChange}), + h("label", {}, "y"), + h("input", {type: "number", value: params.pos[1], + onchange: onPosYChange}), + h("label", {}, "Font size"), + h("input", {type: "number", value: params.font_size, + min: 0, max: 88, step: 0.5, + onchange: onFontSizeChange}))); +} + +function EditorViewNetscape({app_state, on_state_change}) +{ + const params = app_state.button_params; + function onLogoURLChange(e) + { + let new_state = structuredClone(app_state); + new_state.button_params.logo_url = e.target.value; + on_state_change(new_state); + } + + function onBGColorChange(e) + { + let new_state = structuredClone(app_state); + new_state.button_params.color_bg = e.target.value; + on_state_change(new_state); + } + + function onTextTopChange(new_params) + { + let new_state = structuredClone(app_state); + new_state.button_params.text_top = new_params; + on_state_change(new_state); + } + + function onTextBottomChange(new_params) + { + let new_state = structuredClone(app_state); + new_state.button_params.text_bottom = new_params; + on_state_change(new_state); + } + + function onRibbonStyleChange(e) + { + if(app_state.button_params.banner.style == e.target.value) + { + return; + } + let new_state = structuredClone(app_state); + let banner = new_state.button_params.banner; + banner.style = e.target.value; + + // With the style change, the text also needs adjustments. We + // will just set it to the style’s default. + if(banner.style == "corner_bottom_right") + { + banner.text.pos = [65, 46]; + } + else if(banner.style == "right") + { + banner.text.pos = [44, 54]; + } + + on_state_change(new_state); + } + + function onRibbonColorChange(e) + { + let new_state = structuredClone(app_state); + new_state.button_params.banner.color = e.target.value; + on_state_change(new_state); + } + + function onRibbonTextChange(new_text) + { + let new_state = structuredClone(app_state); + new_state.button_params.banner.text = new_text; + on_state_change(new_state); + } + + return h("div", {}, + h("div", {"class": "ButtonRowLeft"}, + h("label", {}, "Background color"), + h("input", {type: "color", + value: params.color_bg, + onchange: onBGColorChange})), + h("div", {"class": "ButtonRowLeft"}, + h("label", {}, "Logo URL"), + h("input", {type: "url", value: params.logo_url, + onchange: onLogoURLChange})), + h("div", {"class": "LabeledPanel"}, + h("h2", {}, "Main text"), + h(TextControlView, {params: params.text_top, + on_change: onTextTopChange})), + h("div", {"class": "LabeledPanel"}, + h("h2", {}, "Flavor text"), + h(TextControlView, {params: params.text_bottom, + on_change: onTextBottomChange})), + h("div", {"class": "LabeledPanel"}, + h("h2", {}, "Ribbon"), + h("div", {"class": "ButtonRowLeft"}, + h("label", {}, "Style"), + h("select", {value: params.banner.style, + onchange: onRibbonStyleChange}, + h("option", {value: "corner_bottom_right"}, "Bottom right"), + h("option", {value: "right"}, "Vertical right")), + h("label", {}, "Background color"), + h("input", {type: "color", + value: params.banner.color, + onchange: onRibbonColorChange})), + h("div", {"class": "LabeledPanel"}, + h("h2", {}, "Text"), + h(TextControlView, {params: params.banner.text, + on_change: onRibbonTextChange})), + + ), + + + ); +} + +function EditorView({app_state}) +{ + const [state, setState] = preactHooks.useState(app_state); + let preview_view = null; + let editor_view = null; + if(app_state.button_type == "netscape") + { + preview_view = ButtonViewNetscape; + editor_view = EditorViewNetscape; + } + + function onStateChange(new_state) + { + console.debug("New state is", new_state); + setState(new_state); + } + + return h("div", {}, + h(preview_view, {app_state: state}), + h("select", {}, + h("option", {value: "netscape"}, "Netscape"), + ), + h(editor_view, {app_state: state, on_state_change: onStateChange})); +} + +function App() +{ + return h("div", {}, + h(EditorView, {app_state: DEFAULT_APP_STATE})); +} + +preact.render(h(App, {}), document.getElementById("App")); diff --git a/button-maker/scripts/tiles.js b/button-maker/scripts/tiles.js new file mode 100644 index 0000000..f35887a --- /dev/null +++ b/button-maker/scripts/tiles.js @@ -0,0 +1,94 @@ +const TILES = [ + null, + { + id: "blank", + name: "black", + inner_svg: null, + }, { + id: "teleporter", + name: "teleporter", + inner_svg: h("circle", { + "cx": CELL_SIZE * 0.5, "cy": CELL_SIZE * 0.5, + "r": (CELL_SIZE - 10) * 0.5, "stroke": "black", + "fill": "transparent", "stroke-width": 6, + }), + }, { + id: "fleet_command", + name: "fleet command", + inner_svg: h("polyline", { + "points": `3, ${CELL_SIZE} \ +3, ${CELL_SIZE - 15} \ +13, ${CELL_SIZE - 25} \ +13, ${CELL_SIZE - 5} \ +${CELL_SIZE - 13}, ${CELL_SIZE - 5} \ +${CELL_SIZE - 13}, ${CELL_SIZE - 25} \ +${CELL_SIZE - 3}, ${CELL_SIZE - 15} \ +${CELL_SIZE - 3}, ${CELL_SIZE}`, + "fill": "black", "stroke-width": 0, + }), + }, { + id: "scanner", + name: "scanner", + inner_svg: + h(preact.Fragment, {}, + h("circle", {"cx": CELL_SIZE * 0.5, "cy": CELL_SIZE * 0.5, + "r": CELL_SIZE * 0.5 - 2, "fill": "transparent", + "stroke": "black", "stroke-width": 2}), + h("circle", {"cx": CELL_SIZE * 0.5, "cy": CELL_SIZE * 0.5, + "r": CELL_SIZE * 0.25, "fill": "transparent", + "stroke": "black", "stroke-width": 2}), + h("line", {"x1": 2, "y1": CELL_SIZE * 0.5, + "x2": CELL_SIZE - 2, "y2": CELL_SIZE * 0.5, + "stroke": "black", "stroke-width": 2}), + h("line", {"y1": 2, "x1": CELL_SIZE * 0.5, + "y2": CELL_SIZE - 2, "x2": CELL_SIZE * 0.5, + "stroke": "black", "stroke-width": 2}), + h("line", {"x1": CELL_SIZE * 0.5, "y1": CELL_SIZE * 0.5, + "x2": CELL_SIZE * 0.5 + (CELL_SIZE * 0.5 - 2) * Math.cos(Math.PI / 6), + "y2": CELL_SIZE * 0.5 - (CELL_SIZE * 0.5 - 2) * Math.sin(Math.PI / 6), + "stroke": "black", "stroke-width": 2}), + h("circle", {"cx": 22, "cy": 6, "r": 2, "fill": "black", + "stroke-width": 0}), + h("circle", {"cx": 10, "cy": 22, "r": 4, "fill": "black", + "stroke-width": 0}), + + ), + }, { + id: "extractor", + name: "stellar extractor", + inner_svg: + h(preact.Fragment, {}, + h("circle", {"cx": CELL_SIZE * 0.5, "cy": 21, + "r": 10, "fill": "black", "stroke-width": 0}), + h("polygon", {"points": `${CELL_SIZE * 0.5 - 4}, 20 \ +${CELL_SIZE * 0.5 - 4}, 10 \ +${CELL_SIZE * 0.5 - 8}, 10 \ +${CELL_SIZE * 0.5}, 3 \ +${CELL_SIZE * 0.5 + 8}, 10 \ +${CELL_SIZE * 0.5 + 4}, 10 \ +${CELL_SIZE * 0.5 + 4}, 20`, + "stroke": "black", "stroke-width": 2, + "fill": "white"}), + ), + }, { + id: "storage", + name: "storage", + inner_svg: + h(preact.Fragment, {}, + h("rect", {"x": 4, "y": 4, "width": CELL_SIZE - 8, + "height": CELL_SIZE - 8, "rx": 2, "ry": 2, + "fill": "black", "stroke-width": 0}), + h("line", {"x1": 4, "y1": 14, "x2": CELL_SIZE - 4, "y2": 14, + "stroke": "white", "stroke-width": 2}), + h("rect", {"x": CELL_SIZE * 0.5 - 4, "y": 10, "width": 8, "height": 8, + "fill": "white", "stroke-width": 0}), + ), + }, { + id: "plant", + name: "double cultivation chamber", + inner_svg: h("path", {"d": "m3.02 17.7a13 13 0 0013 13 13 13 0 00-13-13m13 13a13 13 0 0013-13 13 13 0 00-13 13m8.66-27.4v7.21a8.66 8.66 0 01-8.66 8.66 8.66 8.66 0 01-8.66-8.66v-7.21c1.07 0 2.12.173 3.12.563.793.332 1.5.821 2.09 1.44l3.43-3.43 3.43 3.43c.591-.622 1.3-1.11 2.09-1.44.996-.388 2.05-.563 3.12-.563z", + "fill": "black", "stroke-width": 0}), + } +]; + +const TILE_MAP = Object.fromEntries(TILES.slice(1).map(t => [t.id, t])); -- cgit v1.2.3-70-g09d2