Source: tg-plot.mjs

const VERSION = '___VERSION___';
const GLOBAL_INSTANCE_NAME = 'p';
const PLOTTER_FUNCTION_NAME = 'plotter';
const HOTKEYS = [ ['metaKey', 'altKey', 'KeyP'], ['ctrlKey', 'altKey', 'KeyP'] ]; // Hotkeys to open plot menu (Cmd/Ctrl + Alt + P)

const SVG_PRECISION = 3; // Number of decimal places (Avoid precision errors, that produce discontinuities in the SVG) (-1 to deactivate limiting)
const SVG_CLIPPING = true; // Perform clipping to scaled viewbox (NOT target viewbox)
const SVG_MIN_LINE_LENGTH = 1/10; // Minimum length of a line in mm (Hopefully avoids belt slipping) (0 to deactivate filtering)
const TARGET_SIZE = [420, 297]; // A3 Landscape, in mm
const SIZES = {
    'A3 Landscape': [420, 297],
    'A3 Portrait' : [297, 420],
    'A4 Landscape': [297, 210],
    'A4 Portrait':  [210, 297],
    'Ledger':  [431.8, 279.4], // 17 x 11 in (Axidraw V3/A3 Maximum)
    'Tabloid': [279.4, 431.8], // 11 x 17 in
    'Letter Landscape': [279.4, 215.9], // 11 x 8.5 in
    'Letter Portrait': [215.9, 279.4], // 8.5 x 11 in
};
const LAYER_COLORS = [ 'black', 'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple' ];
const USE_LAYER_COLORS = true;
const MARGIN = 0.05; // scale down (scaling factor = 1-MARGIN)
const SERVER_URL = [ 'plotter.process.tools', 'plotter-local.process.tools' ];

const CONNECT_ON_START = true;
const WAIT_BEFORE_RECONNECT = 10_000; // ms
const RETRIES = -1 // -1 for unlimited

function create_ui() {
    const tmp = document.createElement('template');
    const format_options = Object.keys(SIZES).reduce( (acc, key) => acc += `<option value="${key}">${key}</option>`, '' ) ;
    const server_options= SERVER_URL.reduce( (acc, url) => acc + `<option value="${url}">`, '');
    
    tmp.innerHTML = `<div id="plotter-ui" style="display:none; font:11px system-ui; width:200px; position:fixed; top:0; right:0; padding:8px; background:rgba(255,255,255,0.66)">
    <div style="font-weight:bold; text-align:center; position:relative;">Plotter<span class="close-button" style="display:inline-block; position:absolute; right:0; cursor:pointer;">✕</span></div>
    <input class="server" placeholder="Server" value="" list="server-list" style="width:193px;"></input><datalist id="server-list">${server_options}</datalist><br>
    <button class="connect" style="margin:5px 5px auto auto;">Connect</button><span class="status">○</span><br>
    <hr>
    <table>
    <style>td:first-child {text-align:right;} input:invalid { outline:2px solid red; }</style>
    <tr> <td>Your ID:</td> <td><input type="text" class="client_id" style="font-weight:bold;" required size=
    12" maxlength="10" min="3" pattern="\\w{3,10}"></input></td> </tr>
    <tr> <td>Lines:</td> <td><span class="lines">–</span></td> </tr>
    <tr class="layers-row"> <td>Layers:</td> <td><span class="layers">–</span></td> </tr>
    <tr> <td>Out of bounds:</td> <td><span class="oob">–</span></td> </tr>
    <tr> <td>Short:</td> <td><span class="short">–</span></td> </tr>
    <tr> <td>Travel:</td> <td><span class="travel">–</span></td> </tr>
    <tr> <td>Ink:</td> <td><span class="ink">–</span></td> </tr>
    <tr> <td>Format:</td> <td><select class="format">${format_options}</select></td> </tr>
    <tr> <td>Speed:</td> <td><input class="speed" placeholder="Drawing Speed (%)" type="number" value="100" min="10" max="100"></input></td> </tr>
    <tr> <td>Plotter queue:</td> <td><span class="queue_len">–</span></td> </tr>
    <tr> <td>Your job:</td> <td><span class="queue_pos">–</span></td> </tr>
    <table>
    <hr>
    <div style="text-align:center";><button class="preview" style="width:80px; margin-right:5px; margin-bottom:0;">Preview</button><button class="savesvg" style="width:80px; margin-bottom:0;">Save SVG</button></div>
    <div style="text-align:center";><!-- <button class="clear">Clear</button> --> <button class="plot" style="width:165px; height:28px; margin-top:5px;" disabled>Plot</button> <!-- <button class="cancel" disabled>Cancel</button></div> -->
    <div class="empty-warning" style="display:none;color:red;">No lines recorded!</div>
    </div> `;
    const div = tmp.content.firstChild;
    document.body.appendChild(div);
    return div;
}

function random_id(len = 10, base = 16, offset = 10) {
    let id = '';
    for (let i=0; i<len; i++) {
        id += (offset + Math.floor(Math.random() * base)).toString(offset+base);
    }
    return id;
}

function to_path(lines, num_decimals = -1) {
    function dec(n) {
        if (num_decimals < 0) { return n; }
        return parseFloat( n.toFixed(num_decimals) ); // parse again to make sure that 1.00 -> 1
    }
    let d = '';
    // current point
    let cx;
    let cy;
    for (let [x0, y0, x1, y1] of lines) {
        if (x0 !== cx || y0 !== cy) { // starting point different than current point
            d += `M ${dec(x0)} ${dec(y0)} L ${dec(x1)} ${dec(y1)} `;
        } else { // continue from current point
            d += `${dec(x1)} ${dec(y1)} `;
        }
        // ending point new current point
        cx = x1;
        cy = y1;
    }
    d = d.trimEnd();
    return d;
}

// Fit viewbox into a target size, with margin
// Returns [sx, sy, cx, cy]
function scale_args_viewbox(viewbox, target_size = [420, 297], margin = 0.05) {
    const center = [ viewbox[0] + viewbox[2]/2, viewbox[1] + viewbox[3]/2 ];
    const scale_w = target_size[0] / viewbox[2];
    const scale_h = target_size[1] / viewbox[3];
    const scale = Math.min(scale_w, scale_h) * (1 - margin);
    return [ scale, scale, center[0], center[1] ];
}

// move to the center given by (cx, cy), then scale by (sx, sy)
function scale_lines(lines, sx = 1, sy = 1, cx = 0, cy = 0) {
    return lines.map( ([x0, y0, x1, y1]) => [
        sx * (x0 - cx),
        sy * (y0 - cy),
        sx * (x1 - cx),
        sy * (y1 - cy) ] );
}

// scale lines from viewbox to target size, with margin
function scale_lines_viewbox(lines, viewbox, target_size, margin) {
    const scale_args = scale_args_viewbox(viewbox, target_size, MARGIN);
    return scale_lines(lines, ...scale_args);
}

// scale viewbox to target size, with margin
// uses scale_lines_viewbox, by treating the viewbox as a line [xmin, ymin, xmax, ymax]
function scale_viewbox(viewbox, target_size, margin) {
    let line = [viewbox[0], viewbox[1], viewbox[0] + viewbox[2], viewbox[1] + viewbox[3]]; // viewbox to line (diagonal)
    line = scale_lines_viewbox([line], viewbox, target_size, margin)[0]; // scaled line
    return [ line[0], line[1], line[2]-line[0], line[3]-line[1] ]; // back to viewbox [x, y, w, h]
}

function get_bbox(lines) {
    let tl = [ Infinity,  Infinity]; // top left
    let br = [-Infinity, -Infinity]; // bottom right
    function check(x, y) {
        if ( x < tl[0] ) { tl[0] = x; }
        if ( x > br[0] ) { br[0] = x; }
        if ( y < tl[1] ) { tl[1] = y; }
        if ( y > br[1] ) { br[1] = y; }
    }
    for (let [x0, y0, x1, y1] of lines) {
        check(x0, y0);
        check(x1, y1);
    }
    return [ tl[0], tl[1], br[0]-tl[0], br[1]-tl[1] ];
}

function timestamp(date = undefined) {
    if (date === undefined) {
        date = new Date();
    }
    function pad(val, digits = 2) {
        return (new String(val)).padStart(digits, '0');
    }
    function tz_offset() {
        const offset = date.getTimezoneOffset() / -60;
        return (offset >= 0 ? '+' : '') + pad(offset, 1);
    }
    return `${date.getFullYear()}${pad(date.getMonth())}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}_UTC${tz_offset()}`;
}

function round(num, precision) {
    const n = 10 ** precision;
    return Math.round((num + Number.EPSILON) * n) / n;
}

function limit_precision(x) {
    if (SVG_PRECISION < 0 || Number.isInteger(x)) { return x; }
    return round(x, SVG_PRECISION);
}

// Returns a promise
async function hash(str) {
    const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(str));
    const array = Array.from(new Uint8Array(buffer));
    return array.map( (b) => b.toString(16).padStart(2, '0') ).join('');
}

function debounce(fn, delay=1000) {
    let blocked = false;
    return function(...args) {
        if (!blocked) {
            blocked = true;
            setTimeout(() => {
                fn(...args);
                blocked = false;
            }, delay);
        }
    };
}

function format_num(number) {
    return new Intl.NumberFormat('en-US').format(number);
}

// https://en.wikipedia.org/wiki/Cohen–Sutherland_algorithm
// Returns clipped line or false if line was completely removed
function clip_line( [x0, y0, x1, y1], [xmin, ymin, xmax, ymax] ) {
    const INSIDE = 0; // 0000
    const LEFT = 1;   // 0001
    const RIGHT = 2;  // 0010
    const BOTTOM = 4; // 0100
    const TOP = 8;    // 1000
    
    function out_code(x, y) {
        let code = INSIDE;  // initialised as being inside of clip window
        if (x < xmin) { code |= LEFT; }          // to the left of clip window
        else if (x > xmax) { code |= RIGHT; }     // to the right of clip window
        if (y < ymin) { code |= BOTTOM; }          // below the clip window
        else if (y > ymax) { code |= TOP; }     // above the clip window
        return code;
    }
    
    let outcode0 = out_code(x0, y0);
    let outcode1 = out_code(x1, y1);
    let accept = false; // Note: unused
    
    while (true) {
        if (!(outcode0 | outcode1)) {
            // bitwise OR is 0: both points inside window; trivially accept and exit loop
            accept = true;
            break;
        } else if (outcode0 & outcode1) {
            // bitwise AND is not 0: both points share an outside zone (LEFT, RIGHT, TOP,
            // or BOTTOM), so both must be outside window; exit loop (accept is false)
            break;
        } else {
            // failed both tests, so calculate the line segment to clip
            // from an outside point to an intersection with clip edge
            let x, y;
            // At least one endpoint is outside the clip rectangle; pick it.
            let outcodeOut = outcode1 > outcode0 ? outcode1 : outcode0;
            // Now find the intersection point;
            if (outcodeOut & TOP) {           // point is above the clip window
                x = x0 + (x1 - x0) * (ymax - y0) / (y1 - y0);
                y = ymax;
            } else if (outcodeOut & BOTTOM) { // point is below the clip window
                x = x0 + (x1 - x0) * (ymin - y0) / (y1 - y0);
                y = ymin;
            } else if (outcodeOut & RIGHT) {  // point is to the right of clip window
                y = y0 + (y1 - y0) * (xmax - x0) / (x1 - x0);
                x = xmax;
            } else if (outcodeOut & LEFT) {   // point is to the left of clip window
                y = y0 + (y1 - y0) * (xmin - x0) / (x1 - x0);
                x = xmin;
            }
            // Now we move outside point to intersection point to clip
            // and get ready for next pass.
            if (outcodeOut == outcode0) {
                x0 = x;
                y0 = y;
                outcode0 = out_code(x0, y0);
            } else {
                x1 = x;
                y1 = y;
                outcode1 = out_code(x1, y1);
            }
        }
    }
    return accept ? [x0, y0, x1, y1] : false;
}

function clip_lines(lines, bounds) {
    lines = lines.map(line => clip_line(line, bounds));
    // filter out removed lines
    lines = lines.filter(line => line !== false);
    return lines;
}

function filter_short_lines(lines, min_len) {
    function len(x0, y0, x1, y1) {
        return Math.sqrt( (x1-x0)**2 + (y1-y0)**2 );
    }
    
    let out = [];   // output lines
    let sx, sy;     // start: start of next line for output
    let ex, ey;     //   end: always end of last encountered line
    let cumlen = 0; // cumulative length, starting from (sx,sy)
    let skipped_segments = 0; // counter for stats (unused)
    
    for (let line of lines) {
        const l = len(...line); // length of current segment
        const [x0, y0, x1, y1] = line;
        
        if (x0 !== ex || y0 !== ey) { // staring point is different from last ending point
            // start of new linestrip
            sx = x0; // set start point for next output
            sy = y0;
            cumlen = 0; // reset length
        }
        
        cumlen += l; // add curent segment length
        ex = x1; // always set end point
        ey = y1;
        
        if (cumlen >= min_len) {
            out.push([ sx, sy, ex, ey ]);
            sx = x1;
            sy = y1;
            cumlen = 0;
        } else {
            skipped_segments += 1;
        }
    }
    return out;
}

// separate lines into layers
function to_layers(lines, layers_indices) {
    if (layers_indices === undefined || layers_indices === 0) {
        // return a single layer with all the lines
        return [ lines ];
    }
    
    // we have at least one layer
    const layers = [];
    let last_idx = 0;
    for (let idx of layers_indices) {
        layers.push( lines.slice(last_idx, idx) );
        last_idx = idx;
    }
    if (lines.length > last_idx) {
        layers.push( lines.slice(last_idx, lines.length) );
    }
    return layers;
}

function to_svg_layers(layers, num_decimals = -1) {
    let svg = '';
    for (let [idx, lines] of layers.entries()) {
        const d = to_path(lines);
        const layer_color = USE_LAYER_COLORS ? LAYER_COLORS[idx % LAYER_COLORS.length] : 'black';
        svg += `    <g id="Layer ${idx}" stroke="${layer_color}" serif:id="Layer ${idx}" inkscape:groupmode="layer" inkscape:label="${idx > 0 ? '!' : ''}${idx} Layer ${idx}">\n`;
        svg += `        <path d="${d}" />\n`;
        svg += '    </g>\n';
    }
    return svg;
}

// TODO: stroke width
// meta { date, format, speed, author } // all optional
async function to_svg(lines, lines_viewbox = null, target_size=[420, 297], meta = {}) {
    // save _layers propery (gets removed by scale_lines_viewbox, map)
    const layer_indices = lines._layers;
    
    if (lines_viewbox === 'bbox') { // calculate bounding box
        lines_viewbox = geb_bbox(lines);
        lines = scale_lines_viewbox(lines, lines_viewbox, target_size, MARGIN);
    } else if (Array.isArray(lines_viewbox)) { // viewbox given [x, y, w, h]
        lines = scale_lines_viewbox(lines, lines_viewbox, target_size, MARGIN);
    }
    
    lines = lines.map(line => line.map(limit_precision));
    
    // separate lines into layers
    const layers = to_layers(lines, layer_indices);
    
    if (SVG_CLIPPING) {
        const scaled_vb = scale_viewbox(lines_viewbox, target_size, MARGIN); // original viewbox scaled up to target size
        let bounds = [ scaled_vb[0], scaled_vb[1], scaled_vb[0] + scaled_vb[2], scaled_vb[1] + scaled_vb[3] ];
        bounds = bounds.map(limit_precision);
        
        // clip per layer
        for (let [idx, lines] of layers.entries() ) {
            layers[idx] = clip_lines(lines, bounds);
        }
    }
    
    if (SVG_MIN_LINE_LENGTH > 0) {
        // filter per layer
        for (let [idx, lines] of layers.entries() ) {
            layers[idx] = filter_short_lines(lines, SVG_MIN_LINE_LENGTH);
        }
    }
    
    const stats = layer_stats(layers); // No need to provide viewbox (out of bounds were clipped already) or scale (lines are already scaled, short lines removed)
    const _timestamp = timestamp(meta.date);
    const data = to_svg_layers(layers);
    
    function coalesce(val, replacement="") {
        return val !== undefined ? val : replacement;
    }
    
    let svg =`<svg xmlns="http://www.w3.org/2000/svg"
     xmlns:tg="https://sketch.process.studio/turtle-graphics"
     xmlns:serif="http://www.serif.com/"
     xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
     tg:version="${VERSION}" tg:count="${stats.count}" tg:layer_count="${stats.layer_count}" tg:oob_count="${stats.oob_count}" tg:short_count="${stats.short_count}" tg:travel="${Math.trunc(stats.travel)}" tg:travel_ink="${Math.trunc(stats.travel_ink)}" tg:travel_blank="${Math.trunc(stats.travel_blank)}" tg:format="${coalesce(meta.format)}" tg:width_mm="${target_size[0]}" tg:height_mm="${target_size[1]}" tg:speed="${coalesce(meta.speed)}" tg:author="${coalesce(meta.author)}" tg:timestamp="${_timestamp}"
     width="${target_size[0]}mm"
     height="${target_size[1]}mm"
     viewBox="-${target_size[0]/2} -${target_size[1]/2} ${target_size[0]} ${target_size[1]}"
     stroke="black" fill="none" stroke-linecap="round">
${data}</svg>`;
    
    svg = `<!-- Created with tg-plot (v${VERSION}) at ${_timestamp} -->\n` + svg;
    const _hash = await hash(svg);
    return { svg, stats, timestamp: _timestamp, hash: _hash };
}

function svg_data_url(svg) {
    return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
}

// viewbox: (Optional) For out-of-bounds counting
// scale:   (Optional) For scaled travels and short_count
function make_line_stats(viewbox = undefined, scale = undefined) {
    const empty = {
        count: 0,
        layer_count: 0,
        oob_count: 0, // out of bounds lines
        short_count: 0, // lines shorter than SVG_MIN_LINE_LENGTH
        travel: 0,
        travel_ink: 0,
        travel_blank: 0
    };
    const stats = Object.assign({}, empty);
    let px;
    let py;
    const lines = [];
    
    function dist(x0, y0, x1, y1) {
        return Math.sqrt( (x1-x0)**2 + (y1-y0)**2 );
    }
    
    function point_out_of_bounds(x, y) {
        const left = viewbox[0], top = viewbox[1], right = left + viewbox[2], bottom = top + viewbox[3];
        return x < left || x > right || y < top || y > bottom;
    }
    
    function add_line(x0, y0, x1, y1, save = true) {
        if (stats.layer_count === 0) { stats.layer_count = 1; }
        if (save) {
            lines.push([x0, y0, x1, y1]); // Save lines for possible recomputation when viewbox or scale change
        }
        let blank = px !== undefined ? dist(px, py, x0, y0) : 0; // blank travel to line start
        let ink = dist(x0, y0, x1, y1); // line
        if (scale !== undefined) {
            blank *= scale;
            ink *= scale;
        }
        stats.count += 1;
        stats.travel_blank += blank;
        stats.travel_ink += ink;
        stats.travel += blank + ink;
        px = x1;
        py = y1;
        if (viewbox !== undefined) {
            if (point_out_of_bounds(x0, y0) || point_out_of_bounds(x1, y1)) { stats.oob_count += 1; }
        }
        if (scale !== undefined) {
            if (ink < SVG_MIN_LINE_LENGTH) { stats.short_count += 1; }
        }
    }
    
    function add_layer(count = 1) {
        stats.layer_count += count;
    }
    
    function get() {
        return Object.assign({}, stats);
    }
    
    let viewbox_changed = false;
    function set_viewbox(new_viewbox) {
        viewbox_changed = true;
        if (JSON.stringify(new_viewbox) === JSON.stringify(viewbox)) {
            viewbox_changed = false;
        }
        viewbox = new_viewbox;
    }
    
    let scale_changed = false;
    function set_scale(new_scale) {
        scale_changed = true;
        if (scale === new_scale || (scale === undefined && new_scale === 1)) {
            scale_changed = false;
        }
        scale = new_scale;
    }
    
    // Recompute stats, only if viewbox or scale were changed
    function update() {
        if (viewbox_changed || scale_changed) {
            Object.assign(stats, empty);
            for (let line of lines) { add_line(...line, false); } // Don't save those lines again!
            viewbox_changed = false;
            scale_changed = false;
        }
        return get();
    }
    
    return { add_line, add_layer, get, update, set_viewbox, set_scale };
}

function line_stats(lines, viewbox = undefined, scale = undefined) {
    const stats = make_line_stats(viewbox, scale);
    for (let line of lines) { stats.add_line(...line); }
    const res = stats.get();
    // add layer_count property
    if (Array.isArray(lines._layers)) {
        res.layer_count = lines._layers.length + 1;
    }
    return res;
}

function layer_stats(layers, viewbox = undefined, scale = undefined) {
    const stats = make_line_stats(viewbox, scale);
    for (let lines of layers) {
        for (let line of lines) { stats.add_line(...line); }
    }
    const res = stats.get();
    res.layer_count = layers.length; // add layer_count property
    return res;
}

// Save text data to file
// Triggers download mechanism in the browser
function save_text(text, filename) {
    let link = document.createElement('a');
    link.download = filename;
    link.href = 'data:text/plain;charset=UTF-8,' + encodeURIComponent(text);
    link.style.display = 'none';     // Firefox
    document.body.appendChild(link); // Firefox
    link.click();
    document.body.removeChild(link); // Firefox
}

function autoconnect(options = {}) {
    const STATE = { 'disconnected':0, 'connecting':1, 'connected':2 };
    
    function callback(fn, ...args) {
        if (typeof fn === 'function') { fn(...args); }
    }
    
    options = Object.assign({
        connect_timeout: 10000,
        wait_before_reconnect: 1000,
        retries: 3, // -1 for unlimited
        on_connecting: undefined,
        on_waiting: undefined,
        on_connected: undefined,
        on_disconnected: undefined,
        on_message: undefined,
        on_error: undefined,
    }, options);
    
    let url;
    let socket;
    let timeout; // timeout used for connecting and waiting for retry
    let state = STATE.disconnected;
    let retries = 0;
    let should_stop = true;
    
    function retry() {
        if (options.retries > -1 && retries + 1 > options.retries) {
            // no (more) retries
            callback(options.on_disconnected);
            return;
        }
        // console.log('retry');
        state = STATE.connecting;
        retries += 1;
        if (options.wait_before_reconnect >= 1000) {
            callback(options.on_waiting, retries);
        }
        timeout = setTimeout(connect, options.wait_before_reconnect);
    }
    
    function on_open(e) {
        clearTimeout(timeout);
        state = STATE.connected;
        callback(options.on_connected, socket);
    }
    
    function on_message(e) {
        callback(options.on_message, e);
    }
    
    function on_close_or_error(e) {
        // can be called due to:
        // * connect timeout (STATE.connecting) -> retry
        // * connection error (STATE.connected) -> retry
        // * intentional abort while connecting (STATE.connecting, should_stop)
        // * intentional abort while connected (STATE.connected, should_stop)
        
        if (state === STATE.disconnected) { return; } // make this idempotent
        // console.log('on_close_or_error', e.type);
        
        socket = null;
        clearTimeout(timeout);
        
        if (should_stop) { // intentional abort
            state = STATE.disconnected;
            callback(options.on_disconnected, e);
        } else {
            if (state === STATE.connected) { retries = 0; }
            state = STATE.disconnected;
            if ( e?.code && ![1000, 1001].includes(e.code) ) {
                callback(options.on_error, e);
            }
            setTimeout(retry, 0);
        }
    }
    
    // make a connection attempt, with timeout and retries.
    function connect() {
        state = STATE.connecting;
        callback(options.on_connecting, retries);
        try {
            socket = new WebSocket(url);
            
            timeout = setTimeout(() => {
                socket?.close();
            }, options.connect_timeout);
            
            socket.onopen  = on_open;
            socket.onclose = on_close_or_error;
            socket.onerror = on_close_or_error;
            socket.onmessage = on_message;
        } catch (e) {
            // catches URL errors -> don't reconnect
            state = STATE.disconnected;
            callback(options.on_disconnected);
        }
    }
    
    function start(url_) {
        if (state !== STATE.disconnected) { return; }
        // console.log('starting');
        url = url_;
        if (!url.toLowerCase().startsWith('wss://')) {
            url = 'wss://' + url;
        }
        retries = 0;
        should_stop = false;
        connect();
    }
    
    function stop() {
        if (state === STATE.disconnected) { return; }
        // console.log('stopping');
        clearTimeout(timeout);
        should_stop = true;
        if (socket) {
            socket.close();
        } else {
            state = STATE.disconnected;
            callback(options.on_disconnected);
        }
    }
    
    function toggle(url_) {
        if (state === STATE.disconnected) { start(url_); return true; }
        else { stop(); return false; }
    }
    
    function socket_() {
        return socket;
    }
    
    function state_() {
        return state;
    }
    
    function send(data) {
        if (socket && socket.readyState === 1) { // OPEN
            socket.send(data);
        }
    }
    
    return { start, stop, toggle, send, socket:socket_, state:state_, STATE };
}

function get_localstorage(key, default_value) {
    let value = localStorage.getItem(key);
    if (value === null) {
        value = default_value;
        localStorage.setItem(key, value);
    }
    return value;
}

function set_localstorage(key, value) {
    localStorage.setItem(key, value);
}

function checkHotkey(hotkey, e) {
    const hotkey_mods = hotkey.slice(0, -1); // all execpt last are modifiers
    hotkey = hotkey.at(-1); // last element is the actual key
    if (e.code !== hotkey) { return false; } // comparing to event.code i.e. "KeyP"
    
    let modifiers = { altKey:false, ctrlKey:false, metaKey:false, shiftKey:false };
    for (let m of hotkey_mods) { modifiers[m] = true; }
    for (let [m, val] of Object.entries(modifiers)) {
        if (e[m] !== val) { return false; }
    }
    return true;
}

function checkHotkeys(hotkeys, e) {
    for (let h of hotkeys) {
        if ( checkHotkey(h, e) ) {
            return true;
        }
    }
    return false;
}

// Set up capturing p5 basic shape functions.
// Supports: point(), line(), rect(), square(), rectMode(), triangle(), quad(), ellipse(), circle(), arc(), ellipseMode()
function capture_p5(p5, record_line, tg_instance = undefined) {
    
    function limit(x, max) {
        if (x > max) { x = max; }
        return x;
    }
        
    // helper for: rect, square
    function render_rect(a, b, c, d, tl = 0, tr = undefined, br = undefined, bl = undefined) {
        if (c === undefined) { return; } // Needs three params
        if (d === undefined) { d = c; }
        if (tr === undefined) { tr = tl; }
        if (br === undefined) { br = tr; }
        if (bl === undefined) { bl = br; }
        
        let x, y, w, h;
        const mode = this._renderer._rectMode;
        if (mode === this.CORNER) {
            [x, y, w, h] = [a, b, c, d];
        } else if (mode === this.CORNERS) {
            [x, y, w, h] = [a, b, c - a, d - b];
        } else if (mode === this.RADIUS) {
            [x, y, w, h] = [a - c, b - d, 2 * c, 2 * d];
        } else if (mode === this.CENTER) {
            [x, y, w, h] = [a - c * 0.5, b - d * 0.5, c, d];
        }
        
        // limit corner radii
        const max_radius = Math.min(h/2, w/2);
        tl = limit(tl, max_radius);
        tr = limit(tr, max_radius);
        br = limit(br, max_radius);
        bl = limit(bl, max_radius);
        
        const orig__ellipseMode = this._renderer._ellipseMode; // set ellipseMode for rendering round corners
        this._renderer._ellipseMode = this.RADIUS;
        
        if (tl > 0) { // TL corner
            render_ellipse.call(this, x + tl, y + tl, tl, tl, this.PI, this.PI + this.HALF_PI);
        }
        if (w > tl + tr) { // only render line if there is actually something between the round corners
            record_line(x + tl, y, x + w - tr, y); // TL to TR
        }
        
        if (tr > 0) { // TR corner
            render_ellipse.call(this, x + w - tr, y + tr, tr, tr, -this.HALF_PI, 0);
        }
        if (h > tr + br) {
            record_line(x + w, y + tr, x + w, y + h - br); // TR to BR
        }
        
        if (br > 0) { // BR corner
            render_ellipse.call(this, x + w - br, y + h - br, br, br, 0, this.HALF_PI);
        }
        if (w > bl + br) {
            record_line(x + w - br, y + h, x + bl, y + h); // br to bl
        }
        
        if (bl > 0) { // BL corner
            render_ellipse.call(this, x + bl, y + h - bl, bl, bl, this.HALF_PI, this.PI);
        }
        if (h > bl + tl) {
            record_line(x, y + h - bl, x, y + tl); // bl to tl
        }
        
        this._renderer._ellipseMode = orig__ellipseMode; // restore ellipseMode
    }
    
    // helper for: triangle, quad
    function render_polygon(...coords) {
        if (coords.length < 4) { return; } // only draw for two or more points
        for (let i = 0; i < coords.length; i += 2) {
            const n = (i + 2 < coords.length) ? i + 2 : 0;
            record_line(coords[i], coords[i + 1], coords[n], coords[n + 1]);
        }
    }
    
    function is_number(x) {
        return typeof x === 'number';
    }
    
    // helper for: ellipse, circle, arc
    const ELLIPSE_SEGMENTS = 60; // 12, 15, 18, 20, 24, 30, 36, 40, 45, 60, 72, 90, 120, 180, 360
    function render_ellipse(a, b, c, d, start = 0, stop = 2 * Math.PI, mode = undefined) {
        if (!is_number(a) || !is_number(b) || !is_number(c)) { return; } // Need at least three params
        if (d === undefined) { d = c; }
        
        start = start % (2 * Math.PI); // normalize angles
        if (stop > 2 * Math.PI) { stop = stop % (2 * Math.PI); } // keep it at 2*PI (or lower), so 0 to 2*PI (full cirlce) is drawn
        if (stop < start) { stop += 2 * Math.PI; } // make sure stop is greater than start
        
        let x, y, r1, r2;
        const ellipse_mode = this._renderer._ellipseMode;
        if (ellipse_mode === this.CENTER) {
            [x, y, r1, r2] = [a, b, c * 0.5, d * 0.5];
        } else if (ellipse_mode === this.RADIUS) {
            [x, y, r1, r2] = [a, b, c, d];
        } else if (ellipse_mode === this.CORNER) {
            r1 = c * 0.5;
            r2 = d * 0.5;
            x = a + r1;
            y = b + r2;
        } else if (ellipse_mode === this.CORNERS) {
            r1 = (c - a) * 0.5;
            r2 = (d - b) * 0.5;
            x = a + r1;
            y = b + r2;
        }
        
        if (!r1 && !r2) { return; } // don't render if both radii are 0, undefined or NaN
        
        function record_segment(start, stop) {
            record_line(
                x + Math.cos(start) * r1,
                y + Math.sin(start) * r2,
                x + Math.cos(stop) * r1,
                y + Math.sin(stop) * r2,
            );
        }
        
        const step = this.radians(360 / ELLIPSE_SEGMENTS);
        let last_angle = start;
        for (let angle = start + step; angle < stop; angle += step) {
            record_segment(angle-step, angle);
            last_angle = angle;
        }
        // add last segment to the endpoint; only if it is > 0.5 degrees (avoids small segments)
        if (this.degrees(stop-last_angle) > 0.5) {
            record_segment(last_angle, stop);
        }
        
        if (mode === this.CHORD) {
            // from stop point to start point
            record_segment(stop, start);
        } else if (mode == this.PIE) {
            // from stop point to center
            record_line(x + Math.cos(stop) * r1, y + Math.sin(stop) * r2, x, y);
            // from center to start
            record_line(x, y, x + Math.cos(start) * r1, y + Math.sin(start) * r2);
        }
        // undefined or OPEN -> do nothing
    }
        
    const replaced = {
        'point': function(...args) {
            if (args.length < 2) { return; } // Need at least 2 args
            const POINT_RADIUS = 0.5;
            const [x, y] = [...args];
            // draw as diamond shape
            record_line(x - POINT_RADIUS, y, x, y - POINT_RADIUS);
            record_line(x, y - POINT_RADIUS, x + POINT_RADIUS, y);
            record_line(x + POINT_RADIUS, y, x, y + POINT_RADIUS);
            record_line(x, y + POINT_RADIUS, x - POINT_RADIUS, y);
        },
        'line': function(...args) {
            if (args.length < 4) { return; } // Need at least 4 args
            record_line(...args);
        },
        'rect': function(...args) {
            render_rect.call(this, ...args);
        },
        'square': function(...args) {
            render_rect.call(this, args[0], args[1], args[2], args[2], args[3], args[4], args[5], args[6]);
        },
        'triangle': function(...args) {
            render_polygon.call(this, ...args);
        },
        'quad': function(...args) {
            render_polygon.call(this, ...args);
        },
        'ellipse': function(...args) {
            render_ellipse.call(this, args[0], args[1], args[2], args[3]);
        },
        'circle': function(...args) {
            render_ellipse.call(this, args[0], args[1], args[2], args[2]);
        },
        'arc': function(...args) {
            render_ellipse.call(this, ...args);
        }
    };
    
    const orig = {}; // object to store original functions
    
    function call_orig(fn_name, self, ...args) {
        return orig[fn_name].call(self, ...args);
    }
    
    for (let [fn_name, fn] of Object.entries(replaced)) {
        orig[fn_name] = p5.prototype[fn_name];
        p5.prototype[fn_name] = function(...args) {
            // add a wrapper so the original function gets called in any case
            try {
                fn.call(this, ...args);
            } catch (e) {
                console.warn(`Error with captured p5 function: ${fn_name}`);
            }
            return call_orig(fn_name, this, ...args);
        };
    }
    // Save uncaptured line function in the instance, so it can be used for plotting
    // Otherwise lines would be plotted double
    if (tg_instance && 'line' in orig) {
        tg_instance['_tg-plot_orig_line_fn'] = orig['line'];
    }
    console.log(`🖨️ → Capturing p5 functions: ${Object.keys(replaced).join(", ")}`);
}


export function make_plotter_client(tg_instance, do_capture_p5 = true) {
    let recording = true;
    let lines = []; // lines as they arrive from tg module (in px)
    lines._layers = []; // array of indices where layers start (except first layer which always starts at 0)
    let line_stats = make_line_stats(); // Can't immediately initialize with tg_instance._p5_viewbox (not yet available) -> Init via line_fn callbacks
    let line_stats_viewbox_initialized = false;
    let plotting = false;
    
    const div = create_ui();
    const lines_span = div.querySelector('.lines');
    const layers_span = div.querySelector('.layers');
    const oob_span = div.querySelector('.oob');
    const short_span = div.querySelector('.short');
    const travel_span = div.querySelector('.travel');
    const ink_span = div.querySelector('.ink');
    const client_id_input = div.querySelector('.client_id');
    // const clear_button = div.querySelector('.clear');
    // const cancel_button = div.querySelector('.cancel');
    const plot_button = div.querySelector('.plot');
    const empty_warning_div = div.querySelector('.empty-warning');
    const status_span = div.querySelector('.status');
    const server_input = div.querySelector('.server');
    const speed_input = div.querySelector('.speed');
    const connect_button = div.querySelector('.connect');
    const queue_pos_span = div.querySelector('.queue_pos');
    const queue_len_span = div.querySelector('.queue_len');
    const preview_button = div.querySelector('.preview');
    const savesvg_button = div.querySelector('.savesvg');
    const format_select = div.querySelector('.format');
    const close_button = div.querySelector('.close-button');
    
    server_input.value = get_localstorage( 'tg-plot:server_url', SERVER_URL[0] );
    client_id_input.value = get_localstorage( 'tg-plot:client_id', random_id(4, 26, 10).toUpperCase() );
    format_select.value = get_localstorage( 'tg-plot:format', 'A3 Landscape' );
    speed_input.value = get_localstorage( 'tg-plot:speed', 100 );
    
    close_button.onmousedown = (e) => {
        if (e.button !== 0) { return; } // left mouse button only
        hide_ui();
    };
    
    client_id_input.onkeydown = (e) => {
        if (e.key == 'Enter') { e.target.blur(); }
    };
    
    client_id_input.onblur = (e) => {
        const valid = e.target.reportValidity();
        if (valid) {
            set_localstorage('tg-plot:client_id', e.target.value );
        }
        if (ac.state() === ac.STATE.connected) {
            plot_button.disabled = !valid;
        }
    };
    
    plot_button.onmouseup = async (e) => { // use mouseup, since this comes after blur
        if (e.button !== 0) { return; } // left mouse button only
        if (!plotting) { // Plot
            if (lines.length == 0) { return; }
            const format = format_select.value;
            const size = SIZES[format]; // target size in mm
            let speed = parseInt(speed_input.value);
            if (isNaN(speed)) { speed = 100; }
            const { svg, timestamp, stats, hash } = await to_svg(lines, tg_instance._p5_viewbox, size, {format, speed, author: client_id_input.value});
            const msg = JSON.stringify({
                type: 'plot',
                client: client_id_input.value,
                svg,
                stats,
                timestamp,
                hash,
                speed,
                format,
                size,
            });
            // console.log(msg);
            ac.send(msg);
        } else { // Cancel
            const msg = JSON.stringify({
                type: 'cancel',
                client: client_id_input.value,
            });
            // console.log(msg);
            ac.send(msg);
        }
    };
    
    preview_button.onmousedown = (e) => {
        if (e.button !== 0) { return; } // left mouse button only
        if (lines.length == 0) { return; }
        
        // Circumvent Pop-up block: https://stackoverflow.com/a/39387533
        const w = window.open();
        if (!w) {
            console.warn(`🖨️ → Preview window was blocked. Please allow the pop-up in your browser and try again!`);
            return;
        }
        
        function make_color_key(num_colors) {
            let out = '<div><style>table {font-size:10px; border:1px solid dimgray; } td {text-align:center} th {text-align:right} .swatch {-webkit-text-stroke-width:0.5px; -webkit-text-stroke-color:dimgray;}</style>';
            out += '<table>';
            out += '<tr><th>Layer</th>';
            for (let i=0; i<num_colors; i++) {
                out += `<td>${i}</td>`;
            }
            out += '</tr>';
            out += '<tr><th>Color</th>';
            for (let i=0; i<num_colors; i++) {
                let color = LAYER_COLORS[i % LAYER_COLORS.length];
                out += `<td class="swatch" style="color:${color}">●</td>`;
            }
            out += '</tr>';
            out += '</table></div>';
            return out;
        }
        
        const size = SIZES[format_select.value]; // target size in mm
        to_svg(lines, tg_instance._p5_viewbox, size).then( ({svg, timestamp, stats, hash}) => {
            const svg_url = svg_data_url(svg);
            const filename = `${timestamp}_${hash.slice(0,5)}.svg`;
            const format_name = format_select.querySelector('option:checked').innerText;
            const color_key = (USE_LAYER_COLORS && stats.layer_count > 1) ? make_color_key(stats.layer_count) : '';
            
            const html = `<html><head><meta charset="utf-8"><title>${filename}</title></head><body style="padding:0; margin:0; height:100%; font:10px system-ui; color:dimgray; background:lightgray; display:flex; flex-direction:column; align-items:center; justify-content:center;"><div><img src="${svg_url}" style="background:white; max-width:90vw; max-height:80vh; box-shadow:3px 3px 10px 1px gray;" /><div class="side-by-side" style="margin-top:2em; display:flex; justify-content:space-between;"><div>${filename}<br>${format_num(stats.count)} line${stats.count != 1 ? 's' : ''}, ${format_num(stats.layer_count)} layer${stats.layer_count != 1 ? 's' : ''}<br>${format_num(Math.floor(stats.travel_ink)/1000)} / ${format_num(Math.floor(stats.travel)/1000)} m<br>${format_name} (${size[0]} ✕ ${size[1]} mm)<br><a href="${svg_url}" download="${filename}" target="_blank" style="color:dimgray;">Download</a></div>${color_key}</div></div></body></html>`;

            // Create Object URL, so we have a reloadable window
            const blob = new Blob([html], { type: 'text/html'});
            const url = URL.createObjectURL(blob);
            w.location = url;
        });
    };
    
    savesvg_button.onmousedown = async (e) => {
        if (e.button !== 0) { return; } // left mouse button only
        if (lines.length == 0) { return; }
        const size = SIZES[format_select.value]; // target size in mm
        const { svg, timestamp, stats, hash } = await to_svg(lines, tg_instance._p5_viewbox, size);
        save_text(svg, `${timestamp}_${hash.slice(0,5)}.svg`);
    };
    
    // Scale factor to proportionally scale the viewbox to the selected format, with margin
    function scale_factor() {
        return tg_instance._p5_viewbox ?
        scale_args_viewbox(tg_instance._p5_viewbox, SIZES[format_select.value], MARGIN)[0] :
        1.0;
    }
    
    function update_stats() {
        line_stats.set_viewbox(tg_instance._p5_viewbox);
        line_stats.set_scale(scale_factor());
        const stats = line_stats.update(); // update stats, if viewbox or scale is different
        
        let unit, travel, ink;
        if (tg_instance._p5_viewbox) {
            unit = ' m';
            travel = Math.floor(stats.travel) / 1000; // from mm to m
            ink = Math.floor(stats.travel_ink) / 1000;
        } else {
            unit = ' px';
            travel = Math.floor(stats.travel);
            ink = Math.floor(stats.travel_ink);
        }
        
        lines_span.innerText = format_num(stats.count);
        lines_span.style.color = stats.count == 0 ? 'red' : '';
        layers_span.innerText = format_num(stats.layer_count);
        layers_span.style.color = stats.layer_count == 0 ? 'red' : '';
        oob_span.innerText = format_num(stats.oob_count);
        oob_span.style.color = stats.oob_count > 0 ? 'red' : '';
        short_span.innerText = format_num(stats.short_count);
        short_span.style.color = stats.short_count > 0 ? 'red' : '';
        travel_span.innerText = format_num(travel) + unit;
        travel_span.style.color = travel == 0 ? 'red' : '';
        ink_span.innerText = format_num(ink) + unit;
        ink_span.style.color = ink == 0 ? 'red' : '';
        empty_warning_div.style.display = stats.count > 0 ? 'none' : 'block';
    }
    const update_stats_debounced = debounce(update_stats, 1000);
    
    format_select.onchange = (e) => {
        update_stats();
        set_localstorage( 'tg-plot:format', e.target.value );
    };
    
    speed_input.onchange = (e) => {
        if (e.target.checkValidity()) {
            set_localstorage( 'tg-plot:speed', e.target.value );
        }
    };
    
    function record_line(...line) {
        if (! line_stats_viewbox_initialized) {
            line_stats.set_viewbox(tg_instance?._p5_viewbox);
            line_stats.set_scale(scale_factor());
            line_stats_viewbox_initialized = true;
        }
        if (!recording) { return; }
        line = line.map(limit_precision);
        lines.push(line);
        line_stats.add_line(...line);
        update_stats_debounced();
    }
    
    tg_instance._add_line_fn(record_line);
    
    if (do_capture_p5 && window.p5) {
        capture_p5(window.p5, record_line, tg_instance);
    }
    
     
     /**
      * An object containing a bunch of functions to control plotting your turtle graphics.
      * <br>
      * Retrieved with {@link plotter}.
      *
      * @typedef {Object} Plotter
      * @property {function} plot - Stop recording lines and show to plotter UI.
      * @property {function} newlayer - Start a new layer. The plotter pauses between layers so the pen can be changed.
      * @property {function} stop - Stop recording lines. Can be used to exclude a part of your drawing from being plotted.
      * @property {function} record - Start recording lines. Recording is enabled from the start, so this function is only useful if <code>stop</code> was used before.
      * @property {function} clear - Clear all recorded lines. Doesn't change if recording is enabled or not.
      * @property {function} isrecording - Returns <code>true</code> if lines are being recorded, <code>false</code> otherwise.
      * @property {function} show - Show the plotter UI.
      * @property {function} hide - Hide the plotter UI.
      * @property {function} isshown - Returns <code>true</code> if the plotter UI is visible, <code>false</code> otherwise.
      * @property {function} lines - (Advanced) Returns an array of all recorded lines so far.
      * @property {function} layers - (Advanced) Returns an array of layers recorded so far. Each layer is an array of lines.
      * @property {function} stats - (Advanced) Returns an object containing statistics for the recorded lines so far.
      */
    const public_fns = {
        clear() {
            lines = [];
            line_stats = make_line_stats(tg_instance?._p5_viewbox, scale_factor());
            lines_span.innerText = '–';
            travel_span.innerText = '–';
            ink_span.innerText = '–';
        },
        
        record(recording_ = true) {
            if (recording_) { recording = true; }
            else { recording = false; }
        },
        
        stop()  {
            recording = false;
        },
            
        isrecording() {
            return recording;
        },
        
        plot() {
            recording = false;
            show_ui();
        },
        
        show() {
            show_ui();
        },
        
        hide() {
            hide_ui();
        },
        
        isshown() {
            return div.style.display !== 'none';
        },
        
        lines() {
            return structuredClone(lines);
        },
        
        layers() {
            return to_layers(lines, lines._layers);
        },
        
        stats() {
            return line_stats.get();
        },
        
        newlayer() {
            // remember index where a new layer starts
            lines._layers.push(lines.length);
            line_stats.add_layer();
        }
    };
    
    const ac = autoconnect({
        wait_before_reconnect: WAIT_BEFORE_RECONNECT,
        retries: RETRIES,
        on_connecting: (retries) => {
            // console.log('on_connecting');
            connect_button.innerText = 'Stop';
            status_span.innerText = `○ Connecting${retries > 0 ? ' (' + retries +')' : ''}...`;
            queue_pos_span.innerText = '–';
            queue_len_span.innerText = '–';
            server_input.disabled = true;
            client_id_input.disabled = false;
            format_select.disabled = false;
            speed_input.disabled = false;
            plot_button.disabled = true;
            plot_button.innerText = 'Plot';
        },
        on_waiting: (retries) => {
            // console.log('on_waiting');
            status_span.innerText = `○ Waiting${retries > 0 ? ' (' + retries +')' : ''}...`;
            server_input.disabled = true;
            client_id_input.disabled = false;
            format_select.disabled = false;
            speed_input.disabled = false;
            plot_button.disabled = true;
            plot_button.innerText = 'Plot';
        },
        on_connected: (socket) => {
            // console.log('on_connected');
            connect_button.innerText = 'Disconnect';
            status_span.innerHTML = '<span style="color:dodgerblue;">●</span> Connected';
            server_input.disabled = true;
            client_id_input.disabled = false;
            format_select.disabled = false;
            speed_input.disabled = false;
            if (client_id_input.checkValidity()) { plot_button.disabled = false; }
            plot_button.innerText = 'Plot';
            plotting = false;
        },
        on_disconnected: (e) => {
            // console.log('on_disconnected');
            connect_button.innerText = 'Connect';
            status_span.innerText = '○ Disconnected';
            queue_pos_span.innerText = '–';
            queue_len_span.innerText = '–';
            server_input.disabled = false;
            client_id_input.disabled = false;
            format_select.disabled = false;
            speed_input.disabled = false;
            plot_button.disabled = true;
            plot_button.innerText = 'Plot';
        },
        on_message: (e) => {
            const msg = JSON.parse(e.data)
            // console.log('on_message', msg);
            if (msg.type === 'error') {
                console.warn(`🖨️ Plotter says: "${msg.msg}"`);
            }
            else if (msg.type === 'queue_length') {
                queue_len_span.innerText = msg.length;
            }
            else if (msg.type === 'queue_position') {
                let pos;
                if (msg.position === 0) {
                    pos = '🖨️📝 Ready to draw, load paper and pen! ';
                    plot_button.disabled = true;
                }
                else if (msg.position === -1) {
                    pos = '🖨️ Drawing...';
                    plot_button.disabled = true;
                }
                else {
                    pos = "⌛ " + msg.position + " before you...";
                    plot_button.disabled = false;
                }
                plotting = true;
                queue_pos_span.innerText = pos;
                client_id_input.disabled = true;
                format_select.disabled = true;
                speed_input.disabled = true;
                plot_button.innerText = 'Cancel';
            }
            else if (msg.type === 'job_done') {
                plotting = false;
                queue_pos_span.innerText = '✅ Done';
                client_id_input.disabled = false;
                format_select.disabled = false;
                speed_input.disabled = false;
                plot_button.innerText = 'Plot';
                plot_button.disabled = false;
            }
            else if (msg.type === 'job_canceled') {
                plotting = false;
                queue_pos_span.innerText = '❌ Canceled';
                client_id_input.disabled = false;
                format_select.disabled = false;
                speed_input.disabled = false;
                plot_button.innerText = 'Plot';
                plot_button.disabled = false;
            }
        },
        on_error: (e) => {
            console.error(`🖨️ Plotter says: "(${e.code}) ${e.reason || 'No reason'} (${e.type ?? 'No type'})"`);
        },
    });
    
    connect_button.onmousedown = (e) => {
        if (e.button !== 0) { return; } // left mouse button only
        const connecting = ac.toggle(server_input.value);
        if (connecting) {
            // connecting
            set_localstorage( 'tg-plot:server_url', server_input.value );
            set_localstorage( 'tg-plot:connect_on_start', 1 );
        } else {
            // disconnecting
            set_localstorage( 'tg-plot:connect_on_start', 0 );
        }
    };
    
    function show_ui() {
        if (div.style.display !== 'none') { return; }
        const connect_on_start = get_localstorage( 'tg-plot:connect_on_start', CONNECT_ON_START );
        if (connect_on_start != '0') {
            ac.start(server_input.value);
        }
        div.style.display = 'block';
        update_stats_debounced();
    }
    
    function hide_ui() {
        div.style.display = 'none';
    }
    
    return public_fns;
}

/*
if (globalThis?.addEventListener !== undefined) {
    const window = globalThis;
    window.addEventListener('DOMContentLoaded', e => {
        if (window?.p5) {
            // console.log('-> p5 detected (%s)', window.p5.VERSION);
            
            // proxy the global preload function
            // this is the earliest the p5 instance is available
            // AND p5 functions are in the global scope (so we can overwrite them)
            const original_preload = window.preload;
            window.preload = (...args) => {
                if (typeof original_preload === 'function') {
                    original_preload(...args);
                }
                console.log('-> (plot) proxied preload');
                if (!window.t) { return; }
                make_plotter_client(window.t);
            };
        }
    });
}
*/


function check_boolean_attr(attr) {
    return attr !== null && attr !== "0" && attr.toLowerCase() !== 'false' && attr.toLowerCase() !== 'no';
}

function check_url_param(param_name) {
    const url = new URL(import.meta.url);
    return check_boolean_attr(url.searchParams.get(param_name)) ? true : false;
}

// Make an object 'callable'
function make_callable(obj, func) {
    const fn = (...args) => func(...args);
    for (let prop of Object.getOwnPropertyNames(obj)) {
        fn[prop] = obj[prop];
    }
    return fn;
}


// browser bootstrap
let _browser_bootstrapped = false;
(function bootstrap_browser() {
    if (_browser_bootstrapped) { return; }
    const window = globalThis;
    if (window.tg?.default_turtle) {
        console.log(`🖨️ Plotter Module (v${VERSION})`);
        
        const do_capture_p5 = check_url_param('capture_p5')
        if (!do_capture_p5) {
            console.log(`🖨️ → p5 Capture disabled`);
        }
        const plotter = make_plotter_client(window.tg.default_turtle, do_capture_p5);
        
        // show plotter UI if "show" query param is set
        const do_show = check_url_param('show');
        if (do_show) { plotter.show(); }
        
        // put plotter instance into global scope
        if (window[GLOBAL_INSTANCE_NAME] === undefined) {
            window[GLOBAL_INSTANCE_NAME] = plotter;
            console.log(`🖨️ → Global plotter: ${GLOBAL_INSTANCE_NAME}`);
        }
        
        /**
          * Get the {@link Plotter} object, containing all the functions to control plotting your turtle graphics.
          *
          * @function plotter
          * @returns The {@link Plotter} object.
          */
        // Put plotter function on tg default intance
        // Add properties from the object to the function as well, so `plotter()` and `plotter` are effectively equivalent
        // Note: No need to add plotter_fn to global scope.
        // Since it's on the default instance, it will be globalized by it
        const plotter_fn = make_callable(plotter, () => plotter);
        if (window.tg.default_turtle[PLOTTER_FUNCTION_NAME] === undefined) {
            window.tg.default_turtle[PLOTTER_FUNCTION_NAME] = plotter_fn;
            console.log(`🖨️ → Plotter function/object (added to turtles): ${PLOTTER_FUNCTION_NAME}`);
        }
        
        
        // Add hotkeys
        window.addEventListener('keydown', (e) => {
            if ( checkHotkeys(HOTKEYS, e) ) {
                if (plotter.isshown()) { plotter.hide(); }
                else { plotter.show(); }
                e.preventDefault();
                e.stopPropagation();
            }
        });
    }
    _browser_bootstrapped = true;
})();