#include "tools/jotpluggler/app.h" #include "tools/jotpluggler/common.h" #include "implot.h" #include #include #include #include #include #include #include #include "json11/json11.hpp" namespace fs = std::filesystem; namespace { struct PythonEvalResult { std::vector xs; std::vector ys; }; struct CustomSeriesTemplate { const char *name; const char *globals_code; const char *function_code; const char *preview_text; int required_additional_sources; const char *requirement_text; }; void write_binary_vector(const fs::path &path, const std::vector &values) { write_file_or_throw(path, values.data(), values.size() * sizeof(double)); } std::vector read_binary_vector(const fs::path &path) { const std::string raw = read_file_or_throw(path); if (raw.size() % sizeof(double) != 0) { throw std::runtime_error("Invalid binary series file: " + path.string()); } std::vector values(raw.size() / sizeof(double)); if (!values.empty()) { std::memcpy(values.data(), raw.data(), raw.size()); } return values; } void write_text_file(const fs::path &path, std::string_view text) { write_file_or_throw(path, text); } fs::path create_custom_series_temp_dir() { const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); const fs::path dir = fs::temp_directory_path() / ("jotpluggler_math_" + std::to_string(::getpid()) + "_" + std::to_string(stamp)); fs::create_directories(dir); return dir; } void reset_custom_series_editor(CustomSeriesEditorState *editor) { *editor = CustomSeriesEditorState{}; } bool add_additional_source(CustomSeriesEditorState *editor, const std::string &path) { if (path.empty() || path == editor->linked_source) return false; if (std::find(editor->additional_sources.begin(), editor->additional_sources.end(), path) != editor->additional_sources.end()) { return false; } editor->additional_sources.push_back(path); return true; } std::string next_custom_curve_name(const Pane &pane) { std::set used; for (const Curve &curve : pane.curves) { if (!curve.label.empty()) { used.insert(curve.label); } if (!curve.name.empty()) { used.insert(curve.name); } } for (int i = 1; i < 1000; ++i) { const std::string candidate = "series" + std::to_string(i); if (used.find(candidate) == used.end()) { return candidate; } } return "series"; } Curve make_custom_curve(const Pane &pane, const std::string &name, const CustomPythonSeries &spec, PythonEvalResult result) { Curve curve; curve.name = name; curve.label = name; curve.color = app_next_curve_color(pane); curve.runtime_only = true; curve.custom_python = spec; curve.xs = std::move(result.xs); curve.ys = std::move(result.ys); return curve; } bool upsert_custom_curve_in_pane(WorkspaceTab *tab, int pane_index, Curve curve) { if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { return false; } Pane &pane = tab->panes[static_cast(pane_index)]; for (Curve &existing : pane.curves) { if (existing.runtime_only && existing.name == curve.name) { existing.visible = true; existing.label = curve.label; existing.custom_python = curve.custom_python; existing.xs = std::move(curve.xs); existing.ys = std::move(curve.ys); return false; } } pane.curves.push_back(std::move(curve)); return true; } std::set collect_custom_series_paths(const CustomPythonSeries &spec, std::string_view globals_code, std::string_view function_code) { std::set paths; if (!spec.linked_source.empty()) { paths.insert(spec.linked_source); } paths.insert(spec.additional_sources.begin(), spec.additional_sources.end()); static const std::regex kPathRegex(R"([tv]\(\s*["']([^"']+)["']\s*\))"); const auto collect_from = [&](std::string_view code) { std::string owned(code); for (std::sregex_iterator it(owned.begin(), owned.end(), kPathRegex), end; it != end; ++it) { paths.insert((*it)[1].str()); } }; collect_from(globals_code); collect_from(function_code); return paths; } PythonEvalResult evaluate_custom_python_series(const AppSession &session, const CustomPythonSeries &spec) { const std::set referenced_paths = collect_custom_series_paths(spec, spec.globals_code, spec.function_code); if (referenced_paths.empty()) throw std::runtime_error("No input series referenced. Set an input timeseries or reference route paths in code."); const fs::path temp_dir = create_custom_series_temp_dir(); try { const fs::path globals_path = temp_dir / "globals.py"; const fs::path code_path = temp_dir / "code.py"; const fs::path manifest_path = temp_dir / "manifest.json"; const fs::path out_t_path = temp_dir / "result.t.bin"; const fs::path out_v_path = temp_dir / "result.v.bin"; write_text_file(globals_path, spec.globals_code); write_text_file(code_path, spec.function_code); json11::Json::array paths_json(session.route_data.paths.begin(), session.route_data.paths.end()); json11::Json::array additional_json(spec.additional_sources.begin(), spec.additional_sources.end()); json11::Json::array series_json; size_t series_index = 0; for (const std::string &path : referenced_paths) { const RouteSeries *series = app_find_route_series(session, path); if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) { throw std::runtime_error("Missing route series " + path); } const std::string prefix = "series_" + std::to_string(series_index++); const fs::path time_path = temp_dir / (prefix + ".t.bin"); const fs::path value_path = temp_dir / (prefix + ".v.bin"); write_binary_vector(time_path, series->times); write_binary_vector(value_path, series->values); series_json.push_back(json11::Json::object{ {"path", path}, {"t", time_path.string()}, {"v", value_path.string()}}); } const json11::Json manifest_json = json11::Json::object{ {"paths", std::move(paths_json)}, {"linked_source", spec.linked_source}, {"additional_sources", std::move(additional_json)}, {"series", std::move(series_json)}, }; write_text_file(manifest_path, manifest_json.dump()); const CommandResult process = run_process_capture_output({ "python3", (repo_root() / "tools" / "jotpluggler" / "math_eval.py").string(), manifest_path.string(), globals_path.string(), code_path.string(), out_t_path.string(), out_v_path.string(), }); if (process.exit_code != 0) { const std::string error_text = util::strip(process.output); throw std::runtime_error(error_text.empty() ? "Python evaluation failed" : error_text); } PythonEvalResult result; result.xs = read_binary_vector(out_t_path); result.ys = read_binary_vector(out_v_path); if (result.xs.size() < 2 || result.xs.size() != result.ys.size()) { throw std::runtime_error("Custom series returned invalid output"); } fs::remove_all(temp_dir); return result; } catch (...) { std::error_code ignore_error; fs::remove_all(temp_dir, ignore_error); throw; } } void refresh_custom_curve_samples(AppSession *session, UiState *state, Curve *curve) { if (!curve->custom_python.has_value()) { return; } if (!session->route_data.has_time_range || session->route_data.series.empty()) { curve->runtime_error_message.clear(); curve->xs.clear(); curve->ys.clear(); return; } try { PythonEvalResult result = evaluate_custom_python_series(*session, *curve->custom_python); curve->runtime_error_message.clear(); curve->xs = std::move(result.xs); curve->ys = std::move(result.ys); } catch (const std::exception &err) { curve->xs.clear(); curve->ys.clear(); const std::string err_text = err.what(); if (session->data_mode == SessionDataMode::Stream && util::starts_with(err_text, "Missing route series ")) { curve->runtime_error_message = err_text; return; } const std::string error_message = std::string("Failed to evaluate custom series \"") + app_curve_display_name(*curve) + "\":\n\n" + err_text; if (curve->runtime_error_message != error_message) { curve->runtime_error_message = error_message; state->error_text = error_message; state->open_error_popup = true; } } } const std::array &custom_series_templates() { static constexpr std::array kTemplates = {{ { .name = "Derivative", .globals_code = "", .function_code = "return np.gradient(value, time)", .preview_text = "return np.gradient(value, time)", .required_additional_sources = 0, .requirement_text = "", }, { .name = "Difference", .globals_code = "", .function_code = "return value - v1", .preview_text = "Requires one additional source timeseries.\n\nreturn value - v1", .required_additional_sources = 1, .requirement_text = "Difference requires one additional source timeseries for v1.", }, { .name = "Smoothing", .globals_code = "window = 20\nweights = np.ones(window) / window", .function_code = "return np.convolve(value, weights, mode='same')", .preview_text = "window = 20\nweights = np.ones(window) / window\n\nreturn np.convolve(value, weights, mode='same')", .required_additional_sources = 0, .requirement_text = "", }, { .name = "Integral", .globals_code = "", .function_code = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt", .preview_text = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt", .required_additional_sources = 0, .requirement_text = "", }, }}; return kTemplates; } void draw_custom_series_help_popup(CustomSeriesEditorState *editor) { if (editor->open_help) { ImGui::OpenPopup("Custom Series Help"); editor->open_help = false; } if (!app_begin_popup_modal("Custom Series Help")) { return; } ImGui::TextUnformatted("Available variables"); ImGui::Separator(); ImGui::BulletText("np: numpy"); ImGui::BulletText("t(path), v(path): timestamps and values for a route series"); ImGui::BulletText("paths: all available route series paths"); ImGui::BulletText("time, value: linked input timeseries"); ImGui::BulletText("t1, v1, t2, v2, ...: additional source timeseries"); ImGui::Spacing(); ImGui::TextWrapped("Write either a single expression like \"return np.gradient(value, time)\" " "or a multi-line Python body that returns an array or a (times, values) tuple."); ImGui::Spacing(); if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } void draw_custom_series_preview(const AppSession &session, CustomSeriesEditorState *editor) { std::vector preview_xs; std::vector preview_ys; std::string preview_label = editor->preview_label; if (editor->preview_is_result && editor->preview_xs.size() > 1 && editor->preview_xs.size() == editor->preview_ys.size()) { preview_xs = editor->preview_xs; preview_ys = editor->preview_ys; if (preview_label.empty()) { preview_label = "Result preview"; } } else if (!editor->linked_source.empty()) { if (const RouteSeries *series = app_find_route_series(session, editor->linked_source); series != nullptr && series->times.size() > 1 && series->times.size() == series->values.size()) { preview_xs = series->times; preview_ys = series->values; preview_label = "Input preview (not result)"; } } if (!preview_xs.empty() && preview_xs.size() == preview_ys.size()) { std::vector plot_xs; std::vector plot_ys; app_decimate_samples(preview_xs, preview_ys, 1200, &plot_xs, &plot_ys); const double preview_x_min = preview_xs.front(); const double preview_x_max = preview_xs.back() > preview_xs.front() ? preview_xs.back() : preview_xs.front() + 1e-6; std::string plot_id = "##custom_series_preview"; if (editor->preview_is_result) { plot_id += "_result_"; plot_id += editor->name.empty() ? preview_label : editor->name; } else if (!editor->linked_source.empty()) { plot_id += "_input_"; plot_id += editor->linked_source; } ImGui::TextUnformatted(preview_label.c_str()); if (!editor->linked_source.empty() && !editor->preview_is_result) { ImGui::SameLine(); ImGui::TextDisabled("%s", editor->linked_source.c_str()); } if (ImPlot::BeginPlot(plot_id.c_str(), ImVec2(-1.0f, std::max(180.0f, ImGui::GetContentRegionAvail().y - 6.0f)), ImPlotFlags_NoTitle | ImPlotFlags_NoMenus | ImPlotFlags_NoLegend)) { ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight, ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit); ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, preview_x_min, preview_x_max); ImPlot::SetupAxisLimits(ImAxis_X1, preview_x_min, preview_x_max, ImPlotCond_Once); ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f"); ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g"); ImPlotSpec spec; spec.LineColor = color_rgb(35, 107, 180); spec.LineWeight = 2.0f; ImPlot::PlotLine("##custom_preview_line", plot_xs.data(), plot_ys.data(), static_cast(plot_xs.size()), spec); ImPlot::EndPlot(); } } else { ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 72.0f); ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133)); ImGui::TextWrapped("Choose an input timeseries or click Preview to evaluate the custom result."); ImGui::PopStyleColor(); } } std::string custom_series_name_status(const Pane &pane, std::string_view name) { const std::string trimmed = util::strip(std::string(name)); if (trimmed.empty()) return "name required"; if (!trimmed.empty() && trimmed.front() == '/') { return "cannot start with /"; } for (const Curve &curve : pane.curves) { if (curve.runtime_only && curve.name == trimmed) return "updates existing curve"; } return "new curve"; } const CustomSeriesTemplate &selected_custom_series_template(const CustomSeriesEditorState &editor) { const auto &templates = custom_series_templates(); return templates[static_cast(std::clamp(editor.selected_template, 0, static_cast(templates.size()) - 1))]; } bool custom_series_template_ready(const CustomSeriesEditorState &editor) { const CustomSeriesTemplate &templ = selected_custom_series_template(editor); return !editor.linked_source.empty() && static_cast(editor.additional_sources.size()) >= templ.required_additional_sources; } bool prepare_custom_series_spec(CustomSeriesEditorState *editor, UiState *state, bool require_name, CustomPythonSeries *out_spec) { editor->name = util::strip(editor->name); editor->linked_source = util::strip(editor->linked_source); for (std::string &path : editor->additional_sources) { path = util::strip(path); } editor->additional_sources.erase( std::remove_if(editor->additional_sources.begin(), editor->additional_sources.end(), [&](const std::string &path) { return path.empty() || path == editor->linked_source; }), editor->additional_sources.end()); if (require_name && editor->name.empty()) { state->error_text = "Custom series name is required."; state->open_error_popup = true; return false; } if (require_name && !editor->name.empty() && editor->name.front() == '/') { state->error_text = "Custom series names may not start with '/'."; state->open_error_popup = true; return false; } *out_spec = CustomPythonSeries{ .linked_source = editor->linked_source, .additional_sources = editor->additional_sources, .globals_code = editor->globals_code, .function_code = editor->function_code, }; return true; } bool preview_custom_series_editor(AppSession *session, UiState *state) { CustomSeriesEditorState &editor = state->custom_series; const CustomSeriesTemplate &templ = selected_custom_series_template(editor); if (editor.linked_source.empty()) { state->error_text = "Choose an input timeseries before previewing."; state->open_error_popup = true; state->status_text = "Custom series preview failed"; return false; } if (static_cast(editor.additional_sources.size()) < templ.required_additional_sources) { state->error_text = templ.requirement_text; state->open_error_popup = true; state->status_text = "Custom series preview failed"; return false; } CustomPythonSeries spec; if (!prepare_custom_series_spec(&editor, state, false, &spec)) return false; try { PythonEvalResult result = evaluate_custom_python_series(*session, spec); editor.preview_label = editor.name.empty() ? "Result preview" : editor.name; editor.preview_xs = std::move(result.xs); editor.preview_ys = std::move(result.ys); editor.preview_is_result = true; state->status_text = "Previewed custom series"; return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Custom series preview failed"; return false; } } bool apply_custom_series_editor(AppSession *session, UiState *state) { WorkspaceTab *tab = app_active_tab(&session->layout, *state); TabUiState *tab_state = app_active_tab_state(state); if (tab == nullptr || tab_state == nullptr) { state->status_text = "No active pane"; return false; } if (tab_state->active_pane_index < 0 || tab_state->active_pane_index >= static_cast(tab->panes.size())) { state->status_text = "No active pane"; return false; } CustomSeriesEditorState &editor = state->custom_series; CustomPythonSeries spec; if (!prepare_custom_series_spec(&editor, state, true, &spec)) return false; try { PythonEvalResult result = evaluate_custom_python_series(*session, spec); const SketchLayout before_layout = session->layout; Pane &pane = tab->panes[static_cast(tab_state->active_pane_index)]; editor.preview_label = editor.name; editor.preview_xs = result.xs; editor.preview_ys = result.ys; editor.preview_is_result = true; const bool inserted = upsert_custom_curve_in_pane(tab, tab_state->active_pane_index, make_custom_curve(pane, editor.name, spec, std::move(result))); state->undo.push(before_layout); state->status_text = inserted ? "Created custom series " + editor.name : "Updated custom series " + editor.name; return true; } catch (const std::exception &err) { state->error_text = err.what(); state->open_error_popup = true; state->status_text = "Custom series failed"; return false; } } } // namespace void open_custom_series_editor(UiState *state, const std::string &preferred_source) { CustomSeriesEditorState &editor = state->custom_series; if (!editor.open && editor.name.empty() && editor.linked_source.empty() && editor.function_code == "return value") { editor.focus_name = true; } if (editor.linked_source.empty() && !preferred_source.empty()) { editor.linked_source = preferred_source; } editor.open = true; editor.request_select = true; } std::string preferred_custom_series_source(const Pane &pane) { for (const Curve &curve : pane.curves) { if (!curve.name.empty() && curve.name.front() == '/') { return curve.name; } if (curve.custom_python.has_value() && !curve.custom_python->linked_source.empty()) { return curve.custom_python->linked_source; } } return {}; } void refresh_all_custom_curves(AppSession *session, UiState *state) { for (WorkspaceTab &tab : session->layout.tabs) { for (Pane &pane : tab.panes) { for (Curve &curve : pane.curves) { refresh_custom_curve_samples(session, state, &curve); } } } } void draw_editor_source_panel(UiState *state, CustomSeriesEditorState &editor) { ImGui::TextWrapped("Input timeseries. Provides arguments time and value:"); ImGui::SetNextItemWidth(-FLT_MIN); input_text_string("##custom_linked_source", &editor.linked_source, ImGuiInputTextFlags_ReadOnly); if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) { editor.linked_source = static_cast(payload->Data); editor.additional_sources.erase( std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source), editor.additional_sources.end()); editor.preview_is_result = false; } ImGui::EndDragDropTarget(); } if (ImGui::Button("Use Selected", ImVec2(120.0f, 0.0f)) && !state->selected_browser_path.empty()) { editor.linked_source = state->selected_browser_path; editor.additional_sources.erase( std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source), editor.additional_sources.end()); editor.preview_is_result = false; } ImGui::SameLine(); if (ImGui::Button("Clear", ImVec2(120.0f, 0.0f))) { editor.linked_source.clear(); editor.preview_is_result = false; } ImGui::Spacing(); ImGui::TextUnformatted("Additional source timeseries:"); ImGui::SameLine(); const CustomSeriesTemplate &tmpl = selected_custom_series_template(editor); if (tmpl.required_additional_sources > 0) { const bool ready = static_cast(editor.additional_sources.size()) >= tmpl.required_additional_sources; ImGui::TextColored(ready ? color_rgb(58, 126, 73) : color_rgb(180, 122, 44), "%s", tmpl.requirement_text); } ImGui::SameLine(); ImGui::BeginDisabled(editor.selected_additional_source < 0 || editor.selected_additional_source >= static_cast(editor.additional_sources.size())); if (ImGui::Button("Remove Selected", ImVec2(140.0f, 0.0f)) && editor.selected_additional_source >= 0 && editor.selected_additional_source < static_cast(editor.additional_sources.size())) { editor.additional_sources.erase(editor.additional_sources.begin() + static_cast(editor.selected_additional_source)); editor.selected_additional_source = editor.additional_sources.empty() ? -1 : std::clamp(editor.selected_additional_source, 0, static_cast(editor.additional_sources.size()) - 1); editor.preview_is_result = false; } ImGui::EndDisabled(); if (ImGui::BeginChild("##custom_additional_sources", ImVec2(0.0f, 156.0f), true)) { if (ImGui::BeginTable("##custom_additional_table", 2, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 42.0f); ImGui::TableSetupColumn("path", ImGuiTableColumnFlags_WidthStretch); for (size_t i = 0; i < editor.additional_sources.size(); ++i) { ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::Text("v%zu", i + 1); ImGui::TableNextColumn(); if (ImGui::Selectable(editor.additional_sources[i].c_str(), editor.selected_additional_source == static_cast(i), ImGuiSelectableFlags_SpanAllColumns)) { editor.selected_additional_source = static_cast(i); } } ImGui::EndTable(); } if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) { if (add_additional_source(&editor, static_cast(payload->Data))) editor.preview_is_result = false; } ImGui::EndDragDropTarget(); } } ImGui::EndChild(); if (ImGui::Button("Add Selected", ImVec2(120.0f, 0.0f))) { for (const std::string &path : state->selected_browser_paths) { if (add_additional_source(&editor, path)) editor.preview_is_result = false; } } ImGui::Spacing(); ImGui::SeparatorText("Function library"); const auto &templates = custom_series_templates(); if (ImGui::BeginChild("##custom_series_template_list", ImVec2(0.0f, 132.0f), true)) { for (size_t i = 0; i < templates.size(); ++i) { if (ImGui::Selectable(templates[i].name, editor.selected_template == static_cast(i), ImGuiSelectableFlags_AllowDoubleClick)) { editor.selected_template = static_cast(i); if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { editor.globals_code = templates[i].globals_code; editor.function_code = templates[i].function_code; editor.preview_is_result = false; } } } } ImGui::EndChild(); if (ImGui::Button("Use Selected Example")) { const auto &sel = selected_custom_series_template(editor); editor.globals_code = sel.globals_code; editor.function_code = sel.function_code; editor.preview_is_result = false; } ImGui::Spacing(); ImGui::TextDisabled("Preview"); ImGui::BeginChild("##custom_series_template_preview", ImVec2(0.0f, 0.0f), true); ImGui::TextUnformatted(selected_custom_series_template(editor).preview_text); ImGui::EndChild(); } void draw_editor_code_panel(CustomSeriesEditorState &editor, const Pane *active_pane) { const std::string name_status = active_pane != nullptr ? custom_series_name_status(*active_pane, editor.name) : "no active pane"; ImGui::TextUnformatted("New name:"); ImGui::SameLine(); const bool name_error = name_status == "name required" || name_status == "cannot start with /"; ImGui::TextColored(name_error ? color_rgb(200, 72, 64) : color_rgb(58, 126, 73), "%s", name_status.c_str()); if (editor.focus_name) { ImGui::SetKeyboardFocusHere(); editor.focus_name = false; } ImGui::SetNextItemWidth(-FLT_MIN); input_text_string("##custom_series_name", &editor.name, ImGuiInputTextFlags_AutoSelectAll); ImGui::Spacing(); ImGui::SeparatorText("Global variables"); ImGui::SameLine(); if (ImGui::SmallButton("Help")) editor.open_help = true; const float globals_h = std::max(96.0f, ImGui::GetContentRegionAvail().y * 0.28f); if (input_text_multiline_string("##custom_series_globals", &editor.globals_code, ImVec2(-FLT_MIN, globals_h), ImGuiInputTextFlags_AllowTabInput)) editor.preview_is_result = false; ImGui::Spacing(); ImGui::TextUnformatted("def calc(time, value):"); const float func_h = std::max(180.0f, ImGui::GetContentRegionAvail().y - 16.0f); if (input_text_multiline_string("##custom_series_function", &editor.function_code, ImVec2(-FLT_MIN, func_h), ImGuiInputTextFlags_AllowTabInput)) editor.preview_is_result = false; } void draw_custom_series_editor(AppSession *session, UiState *state) { CustomSeriesEditorState &editor = state->custom_series; if (!editor.open) return; WorkspaceTab *tab = app_active_tab(&session->layout, *state); TabUiState *tab_state = app_active_tab_state(state); Pane *active_pane = (tab && tab_state && tab_state->active_pane_index >= 0 && tab_state->active_pane_index < static_cast(tab->panes.size())) ? &tab->panes[static_cast(tab_state->active_pane_index)] : nullptr; if (editor.focus_name && active_pane && editor.name.empty()) editor.name = next_custom_curve_name(*active_pane); draw_custom_series_help_popup(&editor); if (ImGui::BeginTabBar("##custom_series_tabs")) { if (ImGui::BeginTabItem("Single Function")) { const float footer_height = ImGui::GetFrameHeightWithSpacing() * 2.0f + 10.0f; if (ImGui::BeginChild("##custom_series_body", ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height)), false)) { if (ImGui::BeginChild("##custom_series_preview_child", ImVec2(0.0f, std::max(200.0f, ImGui::GetContentRegionAvail().y * 0.28f)), true)) draw_custom_series_preview(*session, &editor); ImGui::EndChild(); ImGui::Spacing(); if (ImGui::BeginTable("##custom_series_editor_table", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp, ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y)))) { ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthFixed, 320.0f); ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch); ImGui::TableNextColumn(); if (ImGui::BeginChild("##custom_series_left", ImVec2(0.0f, 0.0f), false)) draw_editor_source_panel(state, editor); ImGui::EndChild(); ImGui::TableNextColumn(); if (ImGui::BeginChild("##custom_series_right", ImVec2(0.0f, 0.0f), false)) draw_editor_code_panel(editor, active_pane); ImGui::EndChild(); ImGui::EndTable(); } } ImGui::EndChild(); ImGui::Spacing(); if (ImGui::Button("New", ImVec2(120.0f, 0.0f))) { reset_custom_series_editor(&editor); if (!state->selected_browser_path.empty()) editor.linked_source = state->selected_browser_path; editor.open = true; editor.focus_name = true; } ImGui::SameLine(); ImGui::BeginDisabled(!custom_series_template_ready(editor)); if (ImGui::Button("Preview Result", ImVec2(120.0f, 0.0f))) preview_custom_series_editor(session, state); ImGui::EndDisabled(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !custom_series_template_ready(editor)) { if (editor.linked_source.empty()) ImGui::SetTooltip("Choose an input timeseries first."); else ImGui::SetTooltip("%s", selected_custom_series_template(editor).requirement_text); } ImGui::SameLine(); if (ImGui::Button("Apply", ImVec2(120.0f, 0.0f))) apply_custom_series_editor(session, state); ImGui::SameLine(); if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { editor.open = false; editor.request_select = false; } ImGui::EndTabItem(); } ImGui::EndTabBar(); } }