PlotlyJS function to plot with up to 4 Y-axes

Below an idea for a PlotlyJS-based user defined plotting function.
The function can be called with an x-vector and an y-vector/-array.
And it can handle up to 4-y-axes as well.
I am happy about comments.

"""

    priv_plot_data(x_vec, y_array, ...) or
    priv_plot_data([], y_array, ...)

        Gives back a PlotlyJS-Print-Object.

# Inputs:
* `x_vec`:          x-values
* `y_data`:         y-data matrix (trace data points)
* `trace_labels`:   trace lables
* `title_and_x_axis_label`:  two element string vector
* `y_axis_labels`:  coloured trace specific labels on each axis
* `axes_bindings`:  bind a trace to one of the y-axes

# Named Inputs (optional):
* `color_palette`:  your own colorway
* `axis_positions`: seven-element vector to define the horizontal location of the y-axes
* `in_xDBG`:        variable name or label for exspression to be investigated

# Output:
    PlotlyJS-Print-Object

# Current Developer:
    Stefan Pofahl (12-03-2026)

"""
function priv_plot_traces(in_x_vec::AbstractVector,         in_y_data::Union{AbstractVector, AbstractMatrix},
        in_trace_labels::AbstractVector=[""],               in_title_and_x_axis_label::AbstractVector=[""],
        in_y_axis_labels::AbstractVector=[""],              in_axes_bindings::AbstractVector=[];
        color_palette::AbstractVector=[""],                 in_xDBG::Bool=false,
        axis_positions::AbstractVector{<:Real}=Float64[])
    #: --- ToDo -----------------------------------------------------------------------
    #:  optioonal parameters:
    #:      - xMarkerON (vector of int, 0= false, 1= true)
    #:      - define the position of the legend (can also be used to switch off the legend)
    #: --------------------------------------------------------------------------------
    data_pts = size(in_y_data, 1)
    traces_count = size(in_y_data, 2)
    if isempty(in_x_vec)
        in_x_vec = collect(1:data_pts)
    end
    if isempty(color_palette) || color_palette == [""]
        color_palette = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
                         "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]
    end
    if isempty(in_title_and_x_axis_label[1]); in_title_and_x_axis_label = ["", "x-axis"]; end;
    yaxis_list   = ["y", "y2", "y3", "y4"]
    yaxis_keys   = [:yaxis2, :yaxis3, :yaxis4]
    max_y_axes   = length(yaxis_list)
    #: ---
    in_trace_labels = Vector{String}(undef, traces_count); fill!(in_trace_labels, "")
    if isempty(in_trace_labels[1])
        for i = 1:traces_count
            in_trace_labels[i] = string("y", i);
        end
    end;
    #: ---
    in_y_axis_labels = Vector{String}(undef, traces_count); fill!(in_y_axis_labels, "")
    if isempty(in_y_axis_labels[1])
        for i = 1:traces_count
            in_y_axis_labels[i] = string("tr", i);
        end
    end;
    if isempty(in_axes_bindings); in_axes_bindings = ones(Int, traces_count); end;

    # ── Input validation ──────────────────────────────────────────────────────
    length(in_title_and_x_axis_label) != 2   && error("in_title_and_x_axis_label needs exactly 2 elements [title, x-label]")
    data_pts != size(in_y_data, 1)           && error("Y-Data rows $(size(in_y_data,1)) ≠ x-vector length $data_pts")
    traces_count != length(in_trace_labels)      && error("y_labels length $(length(in_trace_labels)) ≠ data columns $traces_count")
    length(in_y_axis_labels) != traces_count && error("in_y_axis_labels must have length $traces_count (one per trace)")
    traces_count != length(in_axes_bindings) && error("axes_bindings length $(length(in_axes_bindings)) ≠ data columns $traces_count")
    !isempty(axis_positions) && length(axis_positions) != 7 && error("axis_positions must have exactly 7 elements")

    in_axes_bindings = Int.(round.(in_axes_bindings))
    used_axes        = sort(unique(in_axes_bindings))
    right_spacing    = 0.12 # in case axis_positions is not specified

    # ── Decode geometry from axis_positions (or auto-compute) ────────────────
    #
    #  axis_positions layout:
    #   [1] domain_start   [2] domain_end
    #   [3] pos_y3_left    [4] pos_y4_left      (-1 → that axis goes right)
    #   [5] pos_right1     [6] pos_right2  [7] pos_right3   (-1 → slot unused)
    #
    #  Decision rule:
    #   axis_positions[3] == -1  →  all secondary axes on right   (≙ case 1)
    #   axis_positions[3] != -1  →  y3 on left, rest on right     (≙ case 2)
    #   axis_positions[4] != -1  →  additionally y4 on left

    left_axes_ids   = [1]                    # y1 always primary-left
    right_axes_ids  = Int[]
    left_positions  = Dict{Int, Float64}()
    right_positions = Dict{Int, Float64}()

    if isempty(axis_positions)
        # ── Auto-compute: all secondary axes right (classic case 1) ──────────
        if length(used_axes) == 2
            right_axes_ids = filter(a -> a > 1, used_axes)
            n_right        = length(right_axes_ids)
            x_domain_start = 0.0
            x_domain_end   = max(0.99, 1.0 - n_right * right_spacing)
            right_positions = Dict(ax => 1.0 - (i - 1) * right_spacing
                                for (i, ax) in enumerate(right_axes_ids))
        elseif length(used_axes) == 3
            right_axes_ids = filter(a -> a > 1, used_axes)
            n_right        = length(right_axes_ids)
            x_domain_start = 0.0
            x_domain_end   = max(0.65, 1.0 - n_right * right_spacing)
            right_positions = Dict(ax => 1.0 - (i - 1) * right_spacing
                                for (i, ax) in enumerate(right_axes_ids))
        else
            right_axes_ids = filter(a -> a > 1, used_axes)
            n_right        = length(right_axes_ids)
            x_domain_start = 0.0
            x_domain_end   = max(0.65, 1.0 - n_right * right_spacing)
            right_positions = Dict(ax => 1.0 - (i - 1) * right_spacing
                                for (i, ax) in enumerate(right_axes_ids))
        end

    else
        # ── Explicit positions from vector ────────────────────────────────────
        x_domain_start  = axis_positions[1]
        x_domain_end    = axis_positions[2]
        pos_left_slots  = axis_positions[3:4]   # indexed by: y3→slot1, y4→slot2
        pos_right_slots = axis_positions[5:7]

        left_slot_map   = Dict(3 => 1, 4 => 2)  # which left slot each axis uses
        right_slot_idx  = 1                      # next available right slot

        for ax in filter(a -> a > 1, used_axes)
            # Check if this axis has a left-slot assignment
            slot = get(left_slot_map, ax, 0)
            if slot > 0 && pos_left_slots[slot] != -1.0
                push!(left_axes_ids, ax)
                left_positions[ax] = pos_left_slots[slot]
            else
                # Validate that a right slot is available
                while right_slot_idx <= 3 && pos_right_slots[right_slot_idx] == -1.0
                    right_slot_idx += 1
                end
                right_slot_idx > 3 && error("Not enough right slots in axis_positions for axis y$ax")
                push!(right_axes_ids, ax)
                right_positions[ax] = pos_right_slots[right_slot_idx]
                right_slot_idx += 1
            end
        end
        n_right = length(right_axes_ids)
    end

    # ── Build colored y-axis title parts ─────────────────────────────────────
    y_title_parts    = Matrix{String}(undef, traces_count, max_y_axes)
    fill!(y_title_parts, "")
    vector_of_traces = PlotlyJS.GenericTrace[]

    for i in 1:traces_count
        color_i  = color_palette[mod1(i, length(color_palette))]
        yAxesNr  = in_axes_bindings[i]

        y_title_parts[i, yAxesNr] = "<span style='color:$color_i'>$(in_y_axis_labels[i])</span>"

        push!(vector_of_traces, PlotlyJS.scatter(;
            x      = in_x_vec,
            y      = in_y_data[:, i],
            mode   = "lines+markers",
            name   = in_trace_labels[i],
            line   = PlotlyJS.attr(color = color_i),
            marker = PlotlyJS.attr(color = color_i),
            yaxis  = yaxis_list[yAxesNr]
        ))
    end

    Y_AxesLabelVector = [join(filter(!isempty, col), " ") for col in eachcol(y_title_parts)]

    # ── Build layout kwargs dynamically ──────────────────────────────────────
    layout_kwargs = Dict{Symbol, Any}(
        :title  => in_title_and_x_axis_label[1],
        :legend => PlotlyJS.attr(x = 0.01, y = 0.99),
        :width  => 1000,
        :height => 600,
        :xaxis  => PlotlyJS.attr(
            title    = in_title_and_x_axis_label[2],
            showgrid = true,
            zeroline = true,
            domain   = [x_domain_start, x_domain_end]
        )
    )

    layout_kwargs[:yaxis] = PlotlyJS.attr(
        title     = Y_AxesLabelVector[1],
        titlefont = PlotlyJS.attr(size = 14),
        showgrid  = true,
        zeroline  = true,
        side      = "left"
    )

    for ax in filter(a -> a != 1, left_axes_ids)
        layout_kwargs[yaxis_keys[ax - 1]] = PlotlyJS.attr(
            title      = Y_AxesLabelVector[ax],
            titlefont  = PlotlyJS.attr(size = 14),
            showgrid   = false,
            zeroline   = false,
            overlaying = "y",
            side       = "left",
            position   = left_positions[ax]
        )
    end

    for ax in right_axes_ids
        layout_kwargs[yaxis_keys[ax - 1]] = PlotlyJS.attr(
            title      = Y_AxesLabelVector[ax],
            titlefont  = PlotlyJS.attr(size = 14),
            showgrid   = false,
            zeroline   = false,
            overlaying = "y",
            side       = "right",
            position   = right_positions[ax]
        )
    end

    layout = PlotlyJS.Layout(; layout_kwargs...)
    plt    = PlotlyJS.plot(vector_of_traces, layout)
    if in_xDBG
        return plt, n_right, x_domain_end, left_positions, right_positions, left_axes_ids, right_axes_ids
    else
        return plt
    end
end