Аппроксимация данных (Engee-приложение)
作者
using Engee, GenieFramework, Stipple, PlotlyBase, Printf
# Начальные демонстрационные данные для графика
# при запуске приложения
const TEST_VEC_X = -10:10;
const TEST_VEC_Y = (-10:10).^2
const TEST_VEC_Y_NOISE = (-10:10).^2 + 10 .* rand(21) .- 5
const INTERPOLATION_POINTS = 32
const MAX_STRING_PREVIEW_LENGTH = 10
# Создает и обновляет массив с объектами для вывода на графике Plotly,
# в который попадают исходные точки и линию аппроксимации (если она была
# выполнена предшествующими функциями)
function create_plot_traces(x_data, y_data, x_interp, y_interp)
traces = [scatter(x=x_data, y=y_data, mode="markers", marker=attr(color="black", size=8, opacity=0.7), name="Данные")]
if !isempty(x_interp)
push!(traces, scatter(x=x_interp, y=y_interp, mode="lines", line=attr(color="royalblue", width=3), name="Аппроксимация"))
end
return traces
end
function create_residual_plot_traces(x_data, y_data, y_interp_x)
x_stem = [v for x in x_data for v in (x, x, NaN)]
y_stem = [v for y in y_data .- y_interp_x for v in (0, y, NaN)]
return [
scatter(x=x_stem, y=y_stem, mode="lines", line=attr(color="gray", width=1), name="Погрешность", showlegend=true),
scatter(x=x_data, y=(y_data .- y_interp_x), mode="markers", marker=attr(color="darkgray", size=8, opacity=0.7), showlegend=false)
]
end
const DEFAULT_PLOT_DATA = [ scatter(x=TEST_VEC_X, y=TEST_VEC_Y, mode="markers", marker=attr(color="skyblue", size=8, opacity=0.7), name="Данные"),
scatter(x=TEST_VEC_X, y=TEST_VEC_Y_NOISE, mode="lines", line=attr(color="lightcyan", width=2), name="Аппроксимация")];
const DEFAULT_PLOT_RESIDUALS = create_residual_plot_traces(TEST_VEC_X, TEST_VEC_Y_NOISE, TEST_VEC_Y);
const EMPTY_PLOT_RESIDUALS = [ scatter(x=[], y=[], mode="sticks", name="Погрешность")];
# Конфигурация layout графика с настройками осей,
# размеров и внешнего вида
#
const PLOT_LAYOUT = PlotlyBase.Layout(
title=attr(text="<b>Результат регрессии</b>", x=0.5, xanchor="center"), font=attr(size=13),
xaxis=attr(title="X", showgrid=true),
yaxis=attr(title="Y", showgrid=true),
margin=attr(l=60, r=20, t=40, b=10),
plot_bgcolor="white", paper_bgcolor="#f8f9fa",
showlegend=true, legend=attr(x=0.02, y=0.98, bgcolor="rgba(255,255,255,0.8)")
)
const PLOT_RESIDUALS_LAYOUT = PlotlyBase.Layout(
title=attr(text="<b>Погрешности</b>", x=0.5, xanchor="center"), font=attr(size=13),
xaxis=attr(title="X", showgrid=true),
yaxis=attr(title="Y", showgrid=true),
margin=attr(l=60, r=20, t=40, b=20),
plot_bgcolor="white", paper_bgcolor="#f8f9fa",
showlegend=true, legend=attr(x=0.02, y=0.98, bgcolor="rgba(255,255,255,0.8)")
)
# Вспомогательная функция для вычисления корреляции
function cor(x, y)
n = length(x)
x_mean = sum(x) / n
y_mean = sum(y) / n
covariance = sum((x .- x_mean) .* (y .- y_mean))
x_std = sqrt(sum((x .- x_mean).^2))
y_std = sqrt(sum((y .- y_mean).^2))
return x_std > 0 && y_std > 0 ? covariance / (x_std * y_std) : 0.0
end
# Функция для вычисления логарифма правдоподобия (для AIC/BIC)
function calculate_log_likelihood(residuals, mse, n)
mse > 0 || return 0.0
-0.5 * n * (log(2π) + log(mse) + 1)
end
function regression_metrics(x_data, y_data, y_fit; p::Int=1)
if isempty(x_data) return "", "", "", "", "", "", "", "", ""; end;
n = length(y_data)
y_mean = sum(y_data) / n
residuals = y_data .- y_fit
sse = sum(residuals.^2) # Sum of Squared Errors (Residual)
dfe = n - p - 1; # Degrees of Freedom Error
# Корреляция
correlation_val = cor(y_data, y_fit)
# Информационные критерии (AIC/BIC)
mse_val = sse / n
log_likelihood = calculate_log_likelihood(residuals, mse_val, n)
aic_val = -2 * log_likelihood + 2 * (p + 2) # +2 для intercept и sigma
bic_val = -2 * log_likelihood + (p + 2) * log(n)
ss_total = sum((y_data .- y_mean).^2)
ss_residual = sum((y_data .- y_fit).^2)
r_squared = 1 - (ss_residual / ss_total)
rmse = sqrt(ss_residual / n)
mae = sum(abs.(y_data .- y_fit)) / n
# Возвращаем именованный кортеж
return @sprintf("%g", r_squared),
@sprintf("%g", 1 - ((1 - r_squared) * (n - 1) / (n - 2))),
@sprintf("%g", rmse),
@sprintf("%g", mae),
@sprintf("%g", sse),
@sprintf("%g", dfe),
@sprintf("%g", correlation_val),
@sprintf("%g", aic_val),
@sprintf("%g", bic_val)
end
# Преобразует вектор в компактное строковое представление для предпросмотра
#
function vector_preview(vec::Vector)
str = string(vec)
length(str) > MAX_STRING_PREVIEW_LENGTH ? str[1:MAX_STRING_PREVIEW_LENGTH-3] * "..." : str
end
# Валидация входных данных из которых будет построен график,
# проверяет доступность и корректность данных и выдает сообщение
#
function validate_input_data(x_data, y_data, use_exponential::Bool)
if isempty(x_data) || isempty(y_data)
return (false, "Ожидание данных...")
end
if length(x_data) != length(y_data)
return (false, "Ошибка: векторы разной длины")
end
if length(x_data) < 2
return (false, "Векторы должны содержать ≥2 элементов")
end
if use_exponential && any(y <= 0 for y in y_data)
return (false, "Для экспоненты все Y должны быть >0")
end
(true, "График построен. Точек: $(length(x_data))")
end
# Полиномиальная аппроксимация методом наименьших квадратов,
# возвращает строку с формулой и интерполированные значения
#
function polynomial_interpolation(vec_x, vec_y, degree::Int)
length(vec_x) < 2 && return "", [], [], []
# Используем полиномиальную регрессию
X = [vec_x.^i for i in 0:degree]
A = hcat(X...)
coefficients = A \ vec_y
# Интерполяция для пробного вектора
x_interp = collect(range(minimum(vec_x), maximum(vec_x), length=INTERPOLATION_POINTS))
X_interp = [x_interp.^i for i in 0:degree]
A_interp = hcat( X_interp... )
y_interp = A_interp * coefficients
# Интерполяция для исходного вектора X
X_interp_x = [vec_x.^i for i in 0:degree]
A_interp_x = hcat( X_interp_x... )
y_interp_x = A_interp_x * coefficients
return "f(x) = " * join(["""$(@sprintf("%g", c)) * x^$(i-1)""" for (i,c) in enumerate(coefficients)], " + " ), x_interp, y_interp, y_interp_x
end
# Экспоненциальная аппроксимация через линеаризацию,
# возвращает форматированную строку формулы и значения кривой
#
function exponential_interpolation(vec_x, vec_y)
any(y <= 0 for y in vec_y) && return "Все значения по Y должны быть положительными", [], [], []
length(vec_x) < 2 && return "Векторы должны состояит из 2 и больше эл-тов", [], [], []
# Линеаризация: ln(y) = ln(a) + b*x
log_y = log.(vec_y)
# Создание матрицы для линейной регрессии
X = [[1.0 for _ in vec_x], vec_x] # [1, x]
A = hcat(X...)
# Решение системы методом наименьших квадратов
coefficients_linear = A \ log_y
# Преобразование обратно в экспоненциальные параметры
a = exp(coefficients_linear[1])
b = coefficients_linear[2]
# Интерполяция на новом наборе точек
x_interp = collect(range(minimum(vec_x), maximum(vec_x), length=INTERPOLATION_POINTS))
y_interp = a .* exp.(b .* x_interp)
# Интерполяция для исходного вектора X
y_interp_x = a .* exp.(b .* vec_x)
return """f(x) = $(@sprintf("%g", a)) exp($(@sprintf("%g", b)) * x)""", x_interp, y_interp, y_interp_x
end
function logistic_regression_with_momentum(vec_x::Vector, vec_y::Vector;
learning_rate::Float64=0.01, momentum::Float64=0.9, max_iterations::Int=5000, tolerance::Float64=1e-8)
n = length(vec_x)
X = [ones(n) vec_x]
coeffs = zeros(2)
velocity = zeros(2)
prev_loss = Inf
for iter in 1:max_iterations
z = X * coeffs
predictions = 1.0 ./ (1.0 .+ exp.(-z))
loss = -sum(vec_y .* log.(predictions .+ 1e-10) .+ (1 .- vec_y) .* log.(1 .- predictions .+ 1e-10)) ./ length(predictions)
if abs(loss - prev_loss) < tolerance break; end
prev_loss = loss
# Градиент
errors = predictions - vec_y
gradient = X' * errors / n
# Обновление с momentum
velocity = momentum * velocity - learning_rate * gradient
coeffs += velocity
end
x_interp = collect(range(minimum(vec_x), maximum(vec_x), length=INTERPOLATION_POINTS))
X = [ones(INTERPOLATION_POINTS) x_interp]
z = X * coeffs
y_interp = 1.0 ./ (1.0 .+ exp.(-z))
# Интерполяция для исходного вектора X
X = [ones(n) vec_x]
z = X * coeffs
y_interp_x = 1.0 ./ (1.0 .+ exp.(-z))
return @sprintf("f(x) = 1/(1+exp(-(%g + %g * x)))", coeffs[1], coeffs[2]), x_interp, y_interp, y_interp_x
end
function normal_density_interpolation_with_momentum(vec_x, vec_y;
learning_rate::Float64=0.1, momentum::Float64=0.9, iterations::Int=5000, tolerance::Float64=1e-8)
length(vec_x) < 2 && return "", [], [], []
# Инициализация параметров
n = length(vec_x)
μ = sum(vec_x) / n
σ = sqrt(sum((vec_x .- μ).^2) / (n - 1))
# Инициализация моментов
velocity_μ = 0.0
velocity_σ = 0.0
prev_loss = Inf
best_μ = μ
best_σ = σ
best_loss = Inf
for iter in 1:iterations
grad_μ = 0.0
grad_σ = 0.0
total_loss = 0.0
for i in 1:length(vec_x)
x = vec_x[i]
y_true = vec_y[i]
# Плотность нормального распределения
exponent = exp(-(x - μ)^2 / (2 * σ^2))
prediction = exponent / (σ * sqrt(2 * π))
# Производные
dμ = prediction * (x - μ) / σ^2
dσ = prediction * ((x - μ)^2 / σ^3 - 1/σ)
# Градиенты MSE
error = prediction - y_true
grad_μ += error * dμ
grad_σ += error * dσ
total_loss += error^2
end
# Средний loss
current_loss = total_loss / length(vec_x)
# Сохраняем лучшие параметры
if current_loss < best_loss
best_loss = current_loss
best_μ = μ
best_σ = σ
end
# Критерий остановки (опционально)
if abs(current_loss - prev_loss) < tolerance break; end
prev_loss = current_loss
# Нормализация градиентов
grad_μ /= length(vec_x)
grad_σ /= length(vec_x)
# Обновление моментов
velocity_μ = momentum * velocity_μ - learning_rate * grad_μ
velocity_σ = momentum * velocity_σ - learning_rate * grad_σ
# Обновление параметров с моментом
μ += velocity_μ
σ += velocity_σ
# Защита от отрицательного σ
σ = max(σ, 0.001)
end
# Используем лучшие найденные параметры
μ, σ = best_μ, best_σ
# Интерполяция
x_interp = collect(range(minimum(vec_x), maximum(vec_x), length=INTERPOLATION_POINTS))
y_interp = [exp(-(x - μ)^2 / (2 * σ^2)) / (σ * sqrt(2 * π)) for x in x_interp]
y_interp_x = [exp(-(x - μ)^2 / (2 * σ^2)) / (σ * sqrt(2 * π)) for x in vec_x]
func_string = @sprintf("f(x) = 1/(%.4f*√(2π)) * exp((-(x-%.4f)^2)/(2*%.4f^2))", σ, μ, σ)
return func_string, x_interp, y_interp, y_interp_x
end
# Маршрутизатор выбора типа аппроксимации
# в зависимости от активированного toggle-переключателя
#
function select_regression_method(x_data, y_data, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
if toggle_linear
return polynomial_interpolation(x_data, y_data, 1);
elseif toggle_quadratic
return polynomial_interpolation(x_data, y_data, 2);
elseif toggle_cubic
return polynomial_interpolation(x_data, y_data, 3);
elseif toggle_exponent
return exponential_interpolation(x_data, y_data)
elseif toggle_logistic
return logistic_regression_with_momentum(x_data, y_data)
elseif toggle_gauss
return normal_density_interpolation_with_momentum(x_data, y_data)
else
return "", [], [], [];
end
end
# Основная функция обновления данных графика,
# координирует валидацию, вычисления и обновление визуализации
#
function update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
# Получение данных
x_data = v1_available ? collect(Engee.engee.genie.recv(v1_name)) : []
y_data = v2_available ? collect(Engee.engee.genie.recv(v2_name)) : []
# Валидация
valid, message = validate_input_data(x_data, y_data, toggle_exponent)
if valid
# Аппроксимация
message, x_interp, y_interp, y_interp_x = select_regression_method(x_data, y_data, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
# Обновление графика
plot_traces = create_plot_traces(x_data, y_data, x_interp, y_interp)
residual_traces = create_residual_plot_traces(x_data, y_data, y_interp_x)
return message, plot_traces, residual_traces, "🟢", "🟢", x_data, y_data, y_interp_x
else
# Один отдельный случай: когда график не валидный, но есть отрицательные точки
if toggle_exponent && length(x_data) == length(y_data)
return message, create_plot_traces(x_data, y_data, [], []), EMPTY_PLOT_RESIDUALS, "🟢", "🟢", [], [], []
else
return message, DEFAULT_PLOT_DATA, DEFAULT_PLOT_RESIDUALS, v1_available ? "🟡" : "🔴", v2_available ? "🟡" : "🔴", [], [], []
end
end
end
# Главный макрос приложения GenieFramework,
# определяет реактивные переменные и их обработчики
#
@app begin
#@in toggle_linear = @in toggle_quadratic = @in toggle_cubic = @in toggle_exponent = @in toggle_logistic = @in toggle_gauss = false
@in toggle_linear = false
@in toggle_quadratic = true
@in toggle_cubic = false
@in toggle_exponent = false
@in toggle_logistic = false
@in toggle_gauss = false
@in selected_option = "Квадратичная функция"
@out message = "Ожидание данных..."
@out v1_status = "🔴"
@out v2_status = "🔴"
@out v1_available = false
@out v2_available = false
@in v1_name = ""
@in v2_name = ""
#@out v1_string_content = ""
#@out v2_string_content = ""
@out plot_data = DEFAULT_PLOT_DATA
@out plot_residuals = DEFAULT_PLOT_RESIDUALS
@out layout = PLOT_LAYOUT
@out layout_residuals = PLOT_RESIDUALS_LAYOUT
@out r_squared = ""
@out adj_r_squared = ""
@out rmse = ""
@out mae = ""
@out SSE = ""
@out DFE = ""
@out Correlation = ""
@out AIC = ""
@out BIC = ""
# Обработчики методов
@onchange toggle_linear begin
if toggle_linear
toggle_quadratic = toggle_cubic = toggle_exponent = toggle_logistic = toggle_gauss = false
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
@onchange toggle_quadratic begin
if toggle_quadratic
toggle_linear = toggle_cubic = toggle_exponent = toggle_logistic = toggle_gauss = false
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
@onchange toggle_cubic begin
if toggle_cubic
toggle_linear = toggle_quadratic = toggle_exponent = toggle_logistic = toggle_gauss = false
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
@onchange toggle_exponent begin
if toggle_exponent
toggle_linear = toggle_quadratic = toggle_cubic = toggle_logistic = toggle_gauss = false
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
@onchange toggle_logistic begin
if toggle_logistic
toggle_linear = toggle_quadratic = toggle_cubic = toggle_exponent = toggle_gauss = false
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
@onchange toggle_gauss begin
if toggle_gauss
toggle_linear = toggle_quadratic = toggle_cubic = toggle_exponent = toggle_logistic = false
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
# Обработчики данных
@onchange v1_name begin
check_vec1 = nothing
try
check_vec1 = collect(Engee.engee.genie.recv(v1_name))
catch e
check_vec1 = nothing;
end
v1_available = check_vec1 isa Vector;
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
@onchange v2_name begin
check_vec2 = nothing
try
check_vec2 = collect(Engee.engee.genie.recv(v2_name))
catch e
check_vec2 = nothing;
end
v2_available = check_vec2 isa Vector;
message, plot_data, plot_residuals, v1_status, v2_status, x_data, y_data, y_fit = update_plot_data(v1_available, v2_available, v1_name, v2_name, toggle_linear, toggle_quadratic, toggle_cubic, toggle_exponent, toggle_logistic, toggle_gauss)
r_squared, adj_r_squared, rmse, mae, SSE, DFE, Correlation, AIC, BIC = regression_metrics(x_data, y_data, y_fit)
end
end
function ui()
row([
column([
p("Входные данные", style="margin-bottom: 8px; color: #333; font-weight: bold; font-size: clamp(13px, 1.6vw, 16px);"),
row([
column([textfield("Вектор X", :v1_name, style="width: 100%; font-size: clamp(11px, 1.3vw, 14px); padding: 2px 2px;")], style="flex: 1; margin-right: 15px;"),
column([
p("{{v1_status}}", style="font-size: clamp(11px, 1.1vw, 13px); color: #666; margin: 0; line-height: 1.3;") #,
], width="35%")
], style="width: 100%; align-items: center;",
),
row([
column([textfield("Вектор Y", :v2_name, style="width: 100%; font-size: clamp(11px, 1.3vw, 14px); padding: 2px 2px;")], style="flex: 1; margin-right: 15px;"),
column([
p("{{v2_status}}", style="font-size: clamp(11px, 1.1vw, 13px); color: #666; margin: 0; line-height: 1.3;")#,
], width="35%")
], style="width: 100%; align-items: center; margin-bottom: 15px; "),
toggle("Линейная функция", :toggle_linear, style="margin-bottom: 1px; font-size: clamp(9px, 1.2vw, 14px);"),
toggle("Квадратичная функция", :toggle_quadratic, style="margin-bottom: 1px; font-size: clamp(9px, 1.2vw, 14px);"),
toggle("Кубическая функция", :toggle_cubic, style="margin-bottom: 1px; font-size: clamp(9px, 1.2vw, 14px);"),
toggle("Экспонента", :toggle_exponent, style="margin-bottom: 1px; font-size: clamp(9px, 1.2vw, 14px);"),
toggle("Логистическая функция", :toggle_logistic, style="margin-bottom: 1px; font-size: clamp(9px, 1.2vw, 14px);"),
toggle("Плотность норм.распр.", :toggle_gauss, style="margin-bottom: 20px; font-size: clamp(9px, 1.2vw, 14px);"),
p("R²: {{r_squared}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 13px);"),
p("Adj R²: {{adj_r_squared}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("RMSE: {{rmse}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("MAE: {{mae}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("Корреляция (y, ŷ): {{Correlation}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("Суммы квадратов SSE: {{SSE}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("Степени свободы DFE: {{DFE}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("Критерий AIC: {{AIC}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);"),
p("Критерий BIC: {{BIC}}", style="margin-bottom: 4px; font-size: clamp(8px, 1.2vw, 14px);")
], style="width: 25%; padding-right: 40px; text-align: left;"),
column([
p("{{message}}", style="text-align: center; width: 100%; margin-bottom: 20px; border: 1px solid #ddd; border-radius: 5px; padding: 12px; background: linear-gradient(120deg, #e0c3fc 0%, #8ec5fc 100%); color: #333; font-weight: bold; font-size: clamp(14px, 1.5vw, 17px); line-height: 1.4;"),
plot(:plot_data, layout=:layout, style="width: 100%; height: 45vh; min-height: 250px; border-radius: 8px; border: 1px solid #eee;"),
plot(:plot_residuals, layout=:layout_residuals, style="width: 100%; height: 25vh; min-height: 180px; border-radius: 8px; border: 1px solid #eee; margin-top: 20px;")
], style="width: 75%; min-height: 500px; background: #fff; padding: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);")
], style="width: 100%; min-height: 85vh; margin: 0; padding: 20px; background: #f5f7fa; box-sizing: border-box; font-size: clamp(14px, 1.6vw, 16px);")
end
# Регистрация корневой страницы приложения
# с указанной UI-функцией
#
@page("/", ui)