среда, 5 ноября 2025 г.

25.10.31, Chad, ChartDirector, Blazor, SVG, NeedFormat

25.10.31, Chad, ChartDirector, Blazor, SVG, NeedFormat

ChartDirector and Blazor - Chad AI

ChartDirector and Blazor

Could you clarify what you’d like to do with ChartDirector and Blazor? For example:

  • How to integrate ChartDirector into a Blazor Server or WebAssembly app
  • Display dynamic/real-time charts
  • Server-side image generation vs. client-side interactivity
  • Deployment setup, licensing, or sample code

If you share your target Blazor model (Server or WASM) and the chart types you need, I can provide a focused walkthrough with code.

GPT-5

Blazor
Проект Blazor
Код в Blazor.Pages
// Chart.cshtml.cs
using Microsoft.AspNetCore.Mvc.RazorPages;
using ChartDirector;

namespace BlazorWebApp01.Pages
{
public class ChartModel : PageModel
{
public void OnGet()
{
ViewData["Title"] = "Simple Bar Chart (1)";
RazorChartViewer viewer = new RazorChartViewer(HttpContext, "chart1");
ViewData["Viewer"] = viewer;
createChart(viewer);
}

//
// Create chart
//
private void createChart(RazorChartViewer viewer)
{
// The data for the bar chart
double[] data = { 85, 156, 179.5, 211, 123 };

// The labels for the bar chart
string[] labels = { "Mon", "Tue", "Wed", "Thu", "Fri" };

// Create a XYChart object of size 250 x 250 pixels
XYChart c = new XYChart(250, 250);

// Set the plotarea at (30, 20) and of size 200 x 200 pixels
c.setPlotArea(30, 20, 200, 200);

// Add a bar chart layer using the given data
c.addBarLayer(data);

// Set the labels on the x axis.
c.xAxis().setLabels(labels);

// Output the chart
viewer.Image = c.makeWebImage(Chart.SVG);

// Include tool tip for the chart
viewer.ImageMap = c.getHTMLImageMap("", "", "title='{xLabel}: {value} GBytes'");
}
}
}
// Chart.cshtml
@page
@model BlazorWebApp01.Pages.ChartModel
@{
Layout = "_Layout";
}

@{
if (ViewBag.Viewer is Array)
{
// Display multiple charts
for (int i = 0; i < ViewBag.Viewer.Length; ++i)
{
@:@Html.Raw(ViewBag.Viewer[i].RenderHTML())
}
}
else
{
// Display one chart only
@:@Html.Raw(ViewBag.Viewer.RenderHTML())
}
}
----------------
ВСЕ прекрасно работает. График рендится в Blazor, но в другом UI
Но в Blazor необходимо работать через
Components/Pages/*.razor
-------------------
Я попробовал написать вот так
@page "/chart"
@attribute [StreamRendering]

<PageTitle>Chart Demo</PageTitle>

<h2>ChartDirector
в Blazor компоненте</h2>
@if (svgString == null)
{
<p><em>Loading chart...</em></p>
}
else
{
@((MarkupString)svgString)
}

@code {
private string? svgString;

protected override async Task OnInitializedAsync()
{
await Task.Delay(500); //
для имитации загрузки, можно убрать

svgString = GenerateChartSvg();
StateHasChanged();
}

private string GenerateChartSvg()
{
//
инициализация ChartDirector-объектов через C#
double[] data = { 85, 156, 179.5, 211, 123 };
string[] labels = { "Mon", "Tue", "Wed", "Thu", "Fri" };
var c = new ChartDirector.XYChart(250, 250);
c.setPlotArea(30, 20, 200, 200);
c.addBarLayer(data);
c.xAxis().setLabels(labels);
string svg = c.makeWebImage(Chart.SVG);
return svg;
}
}
---
НЕ РАБОТАЕТ string svg = c.makeWebImage(Chart.SVG);
как то надо по другому рендить
в Pages это так
viewer.Image = c.makeWebImage(Chart.SVG); -
эта строка работает с view.
--------------------
Как правильно работать в Blazor c .razor - compoment с ChartDIrector
?

Коротко: в .razor‑компоненте ChartDirector можно использовать. Главное — не применять RazorChartViewer и не рассчитывать на ViewData/ViewBag. В Blazor вы напрямую получаете SVG/PNG из ChartDirector и выводите его как разметку (SVG) или как data URL (PNG). Проблема у вас в том, что в Blazor метод возвращает не string, а byte[]/Stream, либо требует web‑контекст. Нужно вызвать правильный API и отрендерить результат корректно.

Варианты для .razor компонента

  1. SVG напрямую в разметку
  • Плюсы: четкий масштаб, интерактивные tooltips через image map ChartDirector, не нужен отдельный static endpoint.
  • Минусы: строка может быть большой.

Пример:

@page "/chart"
@attribute [StreamRendering]

<h2>ChartDirector в Blazor компоненте (SVG)</h2>

@if (svgMarkup is null)
{
<p><em>Loading chart...</em></p>
}
else
{
@((MarkupString)svgMarkup)
}

@code {
private string? svgMarkup;

protected override Task OnInitializedAsync()

{

    svgMarkup = GenerateChartSvg();

    return Task.CompletedTask;

}

 

private string GenerateChartSvg()

{

    double[] data = { 85, 156, 179.5, 211, 123 };

    string[] labels = { "Mon", "Tue", "Wed", "Thu", "Fri" };

 

    var c = new ChartDirector.XYChart(250, 250);

    c.setPlotArea(30, 20, 200, 200);

    c.addBarLayer(data);

    c.xAxis().setLabels(labels);

 

    // 1) Получаем SVG как байты

    // В .NET-обертке чаще всего: c.makeChart(Chart.SVG) -&gt; byte[]

    byte[] svgBytes = c.makeChart(Chart.SVG);

 

    // 2) Конвертируем в строку UTF-8

    string svg = System.Text.Encoding.UTF8.GetString(svgBytes);

 

    // 3) Image Map (tooltips) — для SVG ChartDirector генерирует &lt;title&gt; внутри элементов,

    // либо можно дополнить вручную. Если нужен HTML image map — это для &lt;img&gt; (PNG/JPG), не для inline SVG.

    // Пример включения tooltips для SVG слоя (для баров теги &lt;title&gt; создаются автоматически, если есть layer.setHTMLImageMap)

    // var map = c.getHTMLImageMap("", "", "title='{xLabel}: {value} GBytes'"); // это HTML-имгмэп, не для inline SVG

 

    return svg;

}

}

Ключевые моменты:

  • В Blazor используйте c.makeChart(Chart.SVG), который возвращает byte[] (частая сигнатура в .NET портах). Затем Encoding.UTF8.GetString.
  • Метод makeWebImage/RenderHTML/Viewer — это серверный Razor helper. В компонентах Blazor их не используйте.
  • Для всплывающих подсказок в inline SVG ChartDirector сам может вставить <title> в элементы слоя, если использовать соответствующие методы. Если у вас только HTML image map от getHTMLImageMap, он работает с <img> + usemap, а не с inline SVG.
  1. PNG/JPG как data URL в <img>
  • Плюсы: простая разметка, поддержка getHTMLImageMap для tooltip/links (через usemap).
  • Минусы: растровое изображение, масштабирование хуже.

Пример:

@page "/chart-img"

<h2>ChartDirector в Blazor компоненте (PNG + imagemap)</h2>

@if (imgSrc is null)
{
<p><em>Loading...</em></p>
}
else
{
<img src="@imgSrc" usemap="#mymap" alt="chart" />
@((MarkupString)imageMap) <!--
сюда вставляем <map ...> -->
}

@code {
private string? imgSrc;
private string imageMap = "";

protected override Task OnInitializedAsync()

{

    GenerateChartPngWithMap();

    return Task.CompletedTask;

}

 

private void GenerateChartPngWithMap()

{

    double[] data = { 85, 156, 179.5, 211, 123 };

    string[] labels = { "Mon", "Tue", "Wed", "Thu", "Fri" };

 

    var c = new ChartDirector.XYChart(250, 250);

    c.setPlotArea(30, 20, 200, 200);

    c.addBarLayer(data);

    c.xAxis().setLabels(labels);

 

    // Байтам PNG

    byte[] pngBytes = c.makeChart(Chart.PNG);

 

    // Data URL

    string base64 = Convert.ToBase64String(pngBytes);

    imgSrc = $"data:image/png;base64,{base64}";

 

    // HTML image map для подсказок

    imageMap = c.getHTMLImageMap("",

                                 "",

                                 "title='{xLabel}: {value} GBytes' name='mymap' id='mymap'");

    // ChartDirector обычно возвращает &lt;map name="..."&gt;...&lt;/map&gt;.

    // Если name не вставляется, оберните самостоятельно:

    // imageMap = $"&lt;map name=\"mymap\" id=\"mymap\"&gt;{c.getHTMLImageMap(...без name...)}&lt;/map&gt;";

}

}

Пояснения:

  • В Blazor рендерим PNG как data URL.
  • getHTMLImageMap дает интерактивные подсказки через атрибут title в area.
  • <img usemap="#mymap"> должен совпадать с name/id карты.

Почему у вас не сработало c.makeWebImage(Chart.SVG)

  • Метод makeWebImage и RazorChartViewer ориентированы на ASP.NET MVC/Razor Pages (серверный HTML хелпер), где есть HttpContext и жизненный цикл View.
  • В Blazor надо использовать makeChart(format) и самостоятельно преобразовать результат в строку/картинку.

Blazor Server vs WebAssembly

  • Blazor Server: код ChartDirector исполняется на сервере — это штатный случай. Возвращаете байты и рендерите.
  • Blazor WebAssembly: ChartDirector — нативная библиотека; в браузере (WASM) работать, как правило, не будет. Используйте серверную генерацию (например, через API/фоновый сервис) и отдавайте клиенту результат как SVG/PNG.

Типичные ошибки и решения

  • Пустая строка SVG: убедитесь, что вызываете makeChart(Chart.SVG), затем Encoding.UTF8.GetString(bytes). Не вызывайте makeWebImage в Blazor.
  • Русские подписи/UTF‑8: используйте UTF‑8 при преобразовании байтов в строку.
  • Интерактивность tooltip’ов:
    • Inline SVG: используйте возможности ChartDirector для добавления <title> в элементы. Если нужен именно HTML image map, рендерьте PNG/JPG.
  • Большие SVG в MarkupString: допустимо, но старайтесь не вызывать StateHasChanged слишком часто.

Минимальный рабочий пример (SVG, без image map)

@page "/chart"
<h3>SVG Chart</h3>
@if (svg is null) { <p>Loading...</p> } else { @((MarkupString)svg) }

@code {
private string? svg;
protected override void OnInitialized()
{
var chart = new ChartDirector.XYChart(250, 250);
chart.setPlotArea(30, 20, 200, 200);
chart.addBarLayer(new double[] { 85, 156, 179.5, 211, 123 });
chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

byte[] svgBytes = chart.makeChart(Chart.SVG);

    svg = System.Text.Encoding.UTF8.GetString(svgBytes);

}

}

Если понадобится потоковая/динамическая генерация для нескольких диаграмм, можно выносить генерацию в сервис Singleton/Scoped и возвращать byte[]/string, а в компоненте только отображать.

GPT-5

Спасибо, все заработало.
Теперь нам нужно запустить периодический рендинг с периодом 1 секунда.
Нам необходимо выбрать из трех предыдущих вариантов кода самый подходящий (самый быстрый) для периодического рендинга.
Через каждую секунду в нашем коде мы просто генерим 5 новых значений и вставляем в наш код
double[] data = { 85, 156, 179.5, 211, 123 };
Вот этот массив мы заполняем слyчайными числами от 80 до 180 и выводим в страницу.
Выбери самый подходящий вариант кода для такого рендинга из трех предыдущих.
Наверное, надо предусмотреть Start и Stop.
ЧТобы сервис не работал вовремя Закрытой страницы. Ну это на твое усмотрение.
А вообще лучше сразу давай напишем SingleTone Service для Blazor, который раз в секунду просто обновляет массив новыми значениями. А на странице мы просто считываем текущие данные и генерим график

Отлично! Для периодического рендеринга раз в секунду самый быстрый и предсказуемый вариант — генерировать SVG в компоненте, а данные отдавать из Singleton‑сервиса. Так вы минимизируете работу в UI‑потоке: сервис только обновляет массив, а компонент на таймере вызывает StateHasChanged и пересоздаёт SVG. Генерацию самого SVG тоже можно вынести в сервис, но практической выгоды мало: всё равно нужно дергать перерисовку в компоненте.

Ниже рабочее решение:

  • Singleton‑сервис DataTicker, который раз в секунду обновляет массив из 5 случайных значений [80..180].
  • Start/Stop, плюс авто‑старт один раз при первом подписчике.
  • Компонент подписывается на событие обновления данных, отрисовывает SVG ChartDirector.
  • При уходе со страницы компонент отписывается и останавливает сервис (если подписчиков не осталось).
  1. Регистрация сервиса в Program.cs
    builder.Services.AddSingleton<dataticker>();
  2. Сервис DataTicker (Singleton)
    using System.Timers;

public sealed class DataTicker : IDisposable
{
private readonly object _sync = new();
private readonly Random _rng = new();
private readonly Timer _timer;
private int _subscribers = 0;
private bool _disposed;

// Последние данные

public double[] Current { get; private set; } = new double[5] { 100, 100, 100, 100, 100 };

 

// Вызывается раз в секунду после обновления данных

public event Action? OnTick;

 

public DataTicker()

{

    _timer = new Timer(1000);

    _timer.Elapsed += (_, __) =&gt; Tick();

    _timer.AutoReset = true;

}

 

public void Start()

{

    lock (_sync)

    {

        if (!_timer.Enabled) _timer.Start();

    }

}

 

public void Stop()

{

    lock (_sync)

    {

        if (_timer.Enabled) _timer.Stop();

    }

}

 

// Вызывайте при монтировании/демонтаже компонентов для управления жизненным циклом

public void Subscribe()

{

    lock (_sync)

    {

        _subscribers++;

        if (_subscribers == 1) Start(); // автостарт при первом подписчике

    }

}

 

public void Unsubscribe()

{

    lock (_sync)

    {

        if (_subscribers &gt; 0) _subscribers--;

        if (_subscribers == 0) Stop(); // автостоп, когда никого нет

    }

}

 

private void Tick()

{

    if (_disposed) return;

 

    // Генерим новые 5 значений [80..180]

    var next = new double[5];

    for (int i = 0; i &lt; 5; i++)

        next[i] = _rng.Next(80, 181);

 

    lock (_sync)

    {

        Current = next;

    }

 

    try { OnTick?.Invoke(); } catch { /* гасим ошибки подписчиков */ }

}

 

public void Dispose()

{

    if (_disposed) return;

    _disposed = true;

    _timer.Dispose();

}

}

  1. Компонент Razor, который рисует SVG график (ChartDirector)
    @page "/chart"
    @inject DataTicker Ticker

<h3>Realtime SVG Chart</h3>

@if (svg is null)
{
<p>Loading...</p>
}
else
{
@((MarkupString)svg)
}

@code {
private string? svg;

protected override void OnInitialized()

{

    // Подписка на события сервиса

    Ticker.OnTick += HandleTick;

    Ticker.Subscribe();

 

    // Первичный рендер

    RenderChart(Ticker.Current);

}

 

private void HandleTick()

{

    // Обновляем SVG и перерисовываем компонент

    RenderChart(Ticker.Current);

    InvokeAsync(StateHasChanged);

}

 

private void RenderChart(double[] data)

{

    var chart = new ChartDirector.XYChart(250, 250);

    chart.setPlotArea(30, 20, 200, 200);

    chart.addBarLayer(data);

    chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

 

    byte[] svgBytes = chart.makeChart(Chart.SVG);

    svg = System.Text.Encoding.UTF8.GetString(svgBytes);

}

 

public void Dispose()

{

    // Отписка и авто‑стоп сервиса при отсутствии подписчиков

    Ticker.OnTick -= HandleTick;

    Ticker.Unsubscribe();

}

}

Почему это лучший из трех вариантов для периодического рендеринга

  • Данные отделены от визуализации: сервис обновляет только массив, компонент отвечает только за рендеринг.
  • Минимум аллокаций и работы в UI‑потоке: 1 таймер в сервисе + пересоздание небольшого SVG.
  • Контроль жизненного цикла: авто‑старт/стоп по наличию подписчиков, чтобы не работать на закрытой странице.
  • Простота масштабирования: можно иметь несколько компонентов, все читают один и тот же DataTicker.Current и реагируют на OnTick.

Дополнительно

  • Если будет несколько страниц с разными периодами — вынесите период как параметр сервиса или создайте несколько сервисов.
  • Если нужна кнопка Start/Stop на странице, просто дергайте Ticker.Start()/Ticker.Stop() и отображайте состояние.
  • Для Blazor Server учитывайте, что Timer срабатывает на сервере; для Blazor WebAssembly используйте System.Timers.Timer — он также работает, но нагрузка вся в браузере.

Если нужно, добавлю кнопку Start/Stop и индикацию состояния в компоненте.

GPT-5

Я видимо что-то пропустил.
У меня Страница Chart обновляется только при нажатии Chart в Menu.
График меняется. RandomVAlue работает. Все красиво.
Но только при нажатии "ручки" chart в Menu Blazor
Что я забыл сделать ?

Похоже, у вас таймер/сервис генерит данные, но компонент Chart не получает уведомления о том, что состояние изменилось. В Blazor компонент сам перерисовывается, только если:

  • вы вызвали StateHasChanged() в компоненте
  • или вы обновили параметр/поле, связанное с UI, внутри InvokeAsync(StateHasChanged)
  • или пришло событие (нажатие, навигация), которое приводит к ререндеру

Что проверить и добавить:

  1. Вызывайте StateHasChanged при тике
  • Если у вас есть сервис (Ticker/DataService) с событием OnTick:
    • Подпишитесь на событие в OnInitialized/OnInitializedAsync компонента.
    • В обработчике события вызывайте InvokeAsync(StateHasChanged), чтобы ререндер прошёл в правильном контексте синхронизации.

Пример:
@code {
[Inject] DataTicker Ticker { get; set; } = default!;
private IDisposable? _sub;

protected override void OnInitialized()

{

    // Подписка

    Ticker.OnTick += HandleTick;

    Ticker.Start(); // если автостарт не включён в сервисе

}

 

private void HandleTick(object? sender, EventArgs e)

{

    // обновите данные для графика

    // e.g. Points = Ticker.CurrentPoints;

    InvokeAsync(StateHasChanged);

}

 

public void Dispose()

{

    // Отписка

    Ticker.OnTick -= HandleTick;

}

}

  1. Если используете Timer прямо в компоненте
  • Обязательно вызывайте InvokeAsync(StateHasChanged) в колбэке таймера.

private System.Timers.Timer _timer;

protected override void OnInitialized()
{
_timer = new System.Timers.Timer(1000);
timer.Elapsed += (, __) =>
{
//
обновить данные
// ...
InvokeAsync(StateHasChanged);
};
_timer.AutoReset = true;
_timer.Enabled = true;
}

public void Dispose()
{
_timer?.Stop();
_timer?.Dispose();
}

  1. Если это Blazor Server
  • Любые фоновые потоки обязаны дергать InvokeAsync(StateHasChanged) на компоненте, иначе ререндер не произойдёт.
  • Если обновляете данные в Singleton-сервисе, а компонент только читает, всё равно нужен механизм уведомления (событие/Observable) и вызов StateHasChanged в компоненте.
  1. Проверьте, что график читает актуальные данные
  • Если вы храните массив точек в сервисе, убедитесь, что компонент читает ссылочно актуальную коллекцию или копирует её на каждом тике перед StateHasChanged.
  • Если используете библиотеку графиков, иногда требуется вызвать её метод обновления после изменения данных.
  1. Жизненный цикл компонента
  • Не используйте async void для подписки. Если нужна загрузка данных — OnInitializedAsync, но подписку на события удобнее делать в OnInitialized.
  • Отписывайтесь в Dispose/IDisposable, иначе при повторном открытии страницы будет несколько подписок и «дерганий».

Коротко: добавьте подписку на событие данных и внутри обработчика вызывайте InvokeAsync(StateHasChanged). Именно это вы, вероятно, забыли, поэтому обновление происходит только при клике в меню (навигация триггерит ререндер).

GPT-5

Mы добились того что можем генерить наш Chart в СТранице Blazor.
Генерация одного снимка Chart происходит при нажатии кнопки Chart в Боковой панели.
Однократное нажатие на кнопку Chart вызывает всего один Рендеринг - то есть высвечивается график и замирает. Нам необходимо немного внечти ясности, что нам нужно.
Поскольку кнопка Chart на Боковой имеет глобальный смысл - то есть включает Панель СТраницы и выключает Панель страницы при нажатии любой другой кнопки Меню боковой панели.
Нам нужно конкретизировать Что мы должны делать , когда мы находимся на СТранице Chart, то есть вошли в нее. Сейчас мы начинаем Рендить Chart - это вноситнекоторую путаницу - свомещение смысло входа на страницу. Поэтому мы конкретизируем, что нам нужно.
Необходимо на странице Chаrt Установить Две кнопки Start и Stop.
Смысл этих кнопок понятен Включение Рендеринга - Выключение Рендеринга.
Важно - Когда мы включили Рендеринг Charts через одну секунду и уходим со страницы "Charts"
наш ГЕнератор случайных чисел должен Работать - то есть заполнять буфер Значений для вывода - естественно без отображения, потому что мы с ушли с этой страницы "Chart". Когда мы возвращаемся на страницу Chart у нас снова возобновляется Рендеринг Chart потому что мы вернулись на странцу "Chart" и кнопка Start - Активна. Кнопка Stop - пассивна.
Ну и вариант с включенной кнопки Stop(). ЕСли мы говорим Stop() - то прекращается и Рендеринг и Заполнение Буфера значений. При выходе/входе на странице Charts состояния кнопок Start, Stop должны сохранятся.
Вот наш последний код. Необходимо внести указанные мной изменения. Их много.
---
@page "/chart"
@inject DataTicker Ticker
<h3>Realtime SVG Chart</h3>
@if (svg is null)
{
<p>Loading...</p>
}
else
{
@((MarkupString)svg)
}

@code {
private string? svg;

protected override void OnInitialized()
{
Console.WriteLine("OnInitialized()");
//
Подписка на события сервиса
Ticker.OnTick += HandleTick;
Ticker.Subscribe();

//
Первичный рендер
RenderChart(Ticker.Current);
}

private void HandleTick()
{
Console.WriteLine("HandleTick()");
//
Обновляем SVG и перерисовываем компонент
RenderChart(Ticker.Current);
InvokeAsync(StateHasChanged);
}

private void RenderChart(double[] data)
{
Console.WriteLine("RenderChart()");
var chart = new ChartDirector.XYChart(250, 250);
chart.setPlotArea(30, 20, 200, 200);
chart.addBarLayer(data);
chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

byte[] svgBytes = chart.makeChart(ChartDirector.Chart.SVG);
svg = System.Text.Encoding.UTF8.GetString(svgBytes);
}

public void Dispose()
{
//
Отписка и автостоп сервиса при отсутствии подписчиков
Ticker.OnTick -= HandleTick;
Ticker.Unsubscribe();
}
}
--------------
public sealed class DataTicker : IDisposable
{
// using Timer = System.Timers.Timer
//
Константы границ диапазона
private const int MinValue = 80;
private const int MaxValue = 181;

private const int BufferSize = 5;

private readonly object _sync = new();
private readonly Random _rng = new();
private readonly System.Timers.Timer _timer;
private int _subscribers = 0;
private bool _disposed;

//
Последние данные
//public double[] Current { get; private set; } = new double[5] { 100, 100, 100, 100, 100 };
public double[] Current { get; private set; } = [ 100, 100, 100, 100, 100 ];

//
Вызывается раз в секунду после обновления данных
public event Action? OnTick;

public DataTicker()
{
_timer = new System.Timers.Timer(1000);
_timer.Elapsed += (_, __) => Tick();
_timer.AutoReset = true;
}

public void Start()
{
Console.WriteLine("Start()");

lock (_sync)
{
if (!_timer.Enabled) _timer.Start();
}
}

public void Stop()
{
Console.WriteLine("Stop()");
lock (_sync)
{
if (_timer.Enabled) _timer.Stop();
}
}

//
Вызывайте при монтировании/демонтаже компонентов для управления жизненным циклом
public void Subscribe()
{
Console.WriteLine("Subscribe()");
lock (_sync)
{
_subscribers++;
if (_subscribers == 1) Start(); //
автостарт при первом подписчике
}
}

public void Unsubscribe()
{
Console.WriteLine("Unsubscribe()");
lock (_sync)
{
if (_subscribers > 0) _subscribers--;
if (_subscribers == 0) Stop(); //
автостоп, когда никого нет
}
}

private void Tick()
{
Console.WriteLine($"Tick() Subscribes: {_subscribers}");
if (_disposed) return;

//
Генерим новые 5 значений [80..180]
var next = new double[BufferSize];
for (int i = 0; i < BufferSize; i++)
next[i] = _rng.Next(MinValue, MaxValue);

lock (_sync)
{
Current = next;
}

try { OnTick?.Invoke(); } catch { /*
гасим ошибки подписчиков */ }
}

public void Dispose()
{
Console.WriteLine("Dispose()");
if (_disposed) return;
_disposed = true;
_timer.Dispose();
Console.WriteLine("Dispose() Complete;");
}
}
-------------------
Напоминаю при включении кнопки Start() нгачинается Рендеринг Chart через каждую секунду.
Сейчас при входе на страницу Chart - рендеринг делаетсмя всего один раз.
Чтобы сделать второй раз надо нажимать на кнопу боковой панели chart - это неправильно.
Для этого сы делаем Две кнопки Start, Stop

Ниже — рабочий вариант с двумя кнопками Start/Stop и корректной логикой жизненного цикла:

  • Start: включает генератор данных и подписку на тик, запускает рендер раз в секунду.
  • Stop: отключает генератор данных, снимает подписку, прекращает рендер.
  • При уходе со страницы генератор может продолжать работать (если нажата Start), чтобы буфер продолжал заполняться.
  • При возврате на страницу, если генератор активен — автоматически возобновляется рендер (Start активна, Stop пассивна).
  • Состояние кнопок сохраняется между входами/выходами со страницы.
  • Исключены авто‑старт/стоп по числу подписчиков (иначе генератор бы гасился при уходе со страницы), вместо этого — явное управление Start/Stop.
  • Без async void; обновление UI через InvokeAsync(StateHasChanged).

Изменения состоят из двух частей: 1) страница Chart.razor, 2) сервис DataTicker.

  1. Chart.razor (страница)

@page "/chart"

@implements IDisposable

@inject DataTicker Ticker

 

<h3>Realtime SVG Chart</h3>

 

<div style="margin-bottom:8px;">

    <button @onclick="OnStart" disabled="@Ticker.IsRunning">Start</button>

    <button @onclick="OnStop" disabled="@(!Ticker.IsRunning)">Stop</button>

</div>

 

@if (svg is null)

{

    <p>Loading...</p>

}

else

{

    @((MarkupString)svg)

}

 

@code {

    private string? svg;

 

    protected override void OnInitialized()

    {

        Console.WriteLine("Chart.OnInitialized()");

 

        // Подписка на события сервиса (ТОЛЬКО рендер), генератор не трогаем здесь

        Ticker.OnTick += HandleTick;

 

        // Если генератор уже работает (нажал Start ранее и ушёл со страницы),

        // сразу подписаны и начнём регулярно перерисовываться.

        // Сделаем первичный рендер текущего буфера.

        RenderChart(Ticker.Current);

 

        // Если генератор не работает — просто показываем текущие данные без таймера.

    }

 

    private void HandleTick()

    {

        // Тики приходят из фонового таймера: обновляем график и просим UI перерисоваться.

        RenderChart(Ticker.Current);

        InvokeAsync(StateHasChanged);

    }

 

    private void RenderChart(double[] data)

    {

        var chart = new ChartDirector.XYChart(250, 250);

        chart.setPlotArea(30, 20, 200, 200);

        chart.addBarLayer(data);

        chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

 

        byte[] svgBytes = chart.makeChart(ChartDirector.Chart.SVG);

        svg = System.Text.Encoding.UTF8.GetString(svgBytes);

    }

 

    private void OnStart()

    {

        // Стартуем генератор, если он ещё не запущен

        if (!Ticker.IsRunning)

            Ticker.Start();

 

        // Сделаем моментальный рендер текущего буфера, не дожидаясь первой секунды

        RenderChart(Ticker.Current);

        StateHasChanged();

    }

 

    private void OnStop()

    {

        // Останавливаем генератор и рендеринг (тики перестанут приходить)

        if (Ticker.IsRunning)

            Ticker.Stop();

    }

 

    public void Dispose()

    {

        // При уходе со страницы: отключаемся только от событий рендера,

        // генератор не трогаем — он продолжит работать, если пользователь нажал Start.

        Ticker.OnTick -= HandleTick;

    }

}

  1. DataTicker (сервис)
  • Убраны Subscribe/Unsubscribe и логика авто‑старта по числу подписчиков.
  • Добавлен публичный флаг IsRunning.
  • Start/Stop управляют только таймером генерации и состоянием флага.
  • Генератор продолжает работать независимо от того, есть ли открытая страница.

public sealed class DataTicker : IDisposable

{

    private const int MinValue = 80;

    private const int MaxValue = 181;

    private const int BufferSize = 5;

 

    private readonly object _sync = new();

    private readonly Random _rng = new();

    private readonly System.Timers.Timer _timer;

    private bool _disposed;

 

    public bool IsRunning { get; private set; }

 

    public double[] Current { get; private set; } = new double[BufferSize] { 100, 100, 100, 100, 100 };

 

    public event Action? OnTick;

 

    public DataTicker()

    {

        _timer = new System.Timers.Timer(1000);

        _timer.Elapsed += (_, __) => Tick();

        _timer.AutoReset = true;

        IsRunning = false;

    }

 

    public void Start()

    {

        Console.WriteLine("Ticker.Start()");

        if (_disposed) return;

 

        lock (_sync)

        {

            if (!IsRunning)

            {

                IsRunning = true;

                if (!_timer.Enabled) _timer.Start();

            }

        }

    }

 

    public void Stop()

    {

        Console.WriteLine("Ticker.Stop()");

        if (_disposed) return;

 

        lock (_sync)

        {

            if (IsRunning)

            {

                IsRunning = false;

                if (_timer.Enabled) _timer.Stop();

            }

        }

    }

 

    private void Tick()

    {

        if (_disposed) return;

 

        var next = new double[BufferSize];

        for (int i = 0; i < BufferSize; i++)

            next[i] = _rng.Next(MinValue, MaxValue);

 

        lock (_sync)

        {

            Current = next;

        }

 

        try

        {

            OnTick?.Invoke();

        }

        catch

        {

            // игнорируем исключения подписчиков

        }

    }

 

    public void Dispose()

    {

        Console.WriteLine("Ticker.Dispose()");

        if (_disposed) return;

        _disposed = true;

        _timer.Dispose();

        Console.WriteLine("Ticker.Dispose() Complete;");

    }

}

Поведение теперь соответствует требованиям:

  • На странице есть две кнопки. Start включает генерацию и периодический рендер, Stop всё выключает.
  • Ушли со страницы: страница отписалась от событий, но генератор продолжает работать, если нажата Start; буфер продолжает наполняться.
  • Вернулись: страница подписывается снова и начинает рендерить каждую секунду, если генератор активен. Состояние кнопок берётся из Ticker.IsRunning — Start disabled, Stop enabled при активном генераторе, и наоборот при остановке.
  • Никаких «двойных подписок» и утечек: страница подписывается в OnInitialized и отписывается в Dispose.
  • Нет необходимости повторно нажимать пункт бокового меню: если генератор активен, тики сами приводят к перерисовке. Если нет — можно нажать Start на странице.

GPT-5

При нажатии на кнопку Chart в боковой панели вызываются
Chart.OnInitialized()
RenderChart()
Высвечивается График с Init значениями 100
При нажатии на кнопку Start в СТранице Chart ничего не происходит

Похоже, генератор/тикер не «дотягивается» до компонента Chart после инициализации. В OnInitialized вы один раз рисуете Init=100, но дальше компонент не получает сигналов на перерисовку.

Проверьте ключевые точки:

  1. Источник тиков и подписка
  • Где живёт генератор/тикер? Он должен быть в скоупе Singleton/Scoped сервиса, а не внутри компонента.
  • Компонент Chart должен подписываться на события сервиса в OnInitialized (или OnInitializedAsync) и отписываться в Dispose.
  • Кнопка Start должна вызывать методы того же сервиса (Start/Stop), а не локальную логику компонента.
  1. Вызов StateHasChanged из UI-потока
  • При приходе тика вызывайте перерисовку через InvokeAsync(StateHasChanged), иначе рендер не произойдёт или словите исключение из другого потока.

Пример схемы (Blazor):

// Сервис-тикер (Scoped/Singleton)
public class TickerService
{
private Timer? _timer;
public bool IsRunning { get; private set; }
public event Action? Tick; //
под это подпишется Chart

public void Start()

{

    if (IsRunning) return;

    IsRunning = true;

    _timer = new Timer(_ =&gt; Tick?.Invoke(), null, 0, 1000);

}

 

public void Stop()

{

    if (!IsRunning) return;

    IsRunning = false;

    _timer?.Dispose();

    _timer = null;

}

}

DI в Program.cs:
builder.Services.AddSingleton<tickerservice>();

Компонент Chart.razor:

@inject TickerService Ticker

@code {
protected override void OnInitialized()
{
//
Инициализация графика
RenderChart(Enumerable.Repeat(100, 10).ToArray());

// Подписка на тики

    Ticker.Tick += OnTick;

}

 

private void OnTick()

{

    // Обновляем данные и перерисовываем график

    UpdateData();

    InvokeAsync(StateHasChanged); // важно!

    // Если используете JSChart – вызвать JS-interop обновление тут же

    // await JS.InvokeVoidAsync("chart.update", ...); — через queue + InvokeAsync

}

 

public void RenderChart(int[] values)

{

    // первичный рендер/инициализация чарта

}

 

private void UpdateData()

{

    // добавить новую точку, сдвинуть буфер и т.д.

}

 

public void Dispose()

{

    Ticker.Tick -= OnTick; // обязательно снять подписку

}

}

Кнопки в Chart.razor:

<button @onclick="Start" disabled="@Ticker.IsRunning">Start</button>
<button @onclick="Stop" disabled="@(!Ticker.IsRunning)">Stop</button>

@code {
private void Start() => Ticker.Start();
private void Stop() => Ticker.Stop();
}

Частые причины, почему «Start ничего не делает»:

  • Кнопка вызывает локальный метод, который не обращается к общему сервису, поэтому тиков нет.
  • Подписка на событие не выполнена (забыли Ticker.Tick += OnTick).
  • В OnTick нет InvokeAsync(StateHasChanged) и UI не обновляется.
  • Сервис зарегистрирован как Transient, и компонент получает другой экземпляр, чем кнопка Start. Используйте Singleton или, в случае Scoped, убедитесь, что один и тот же экземпляр в рамках страницы.
  • Исключение в OnTick/JSInterop. Проверьте консоль браузера и лог.

Если у вас есть текущие куски кода Chart, сервиса, DI и обработчиков Start/Stop — пришлите, посмотрю и укажу точную точку разрыва.

GPT-5

namespace BlazorWebApp05
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();

builder.Services.AddSingleton<DataTicker>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}

app.UseHttpsRedirection();

app.UseAntiforgery();

app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

app.Run();
}
}
}
------------
@page "/chart"
@implements IDisposable
@inject DataTicker Ticker

<h3>Realtime SVG Chart</h3>

<div style="margin-bottom:8px;">
<button @onclick="OnStart" disabled="@Ticker.IsRunning">Start</button>
<button @onclick="OnStop" disabled="@(!Ticker.IsRunning)">Stop</button>
</div>


@code {
private void Start() => Ticker.Start();
private void Stop() => Ticker.Stop();
}

@if (svg is null)
{
<p>Loading...</p>
}
else
{
@((MarkupString)svg)
}

@code {
private string? svg;

protected override void OnInitialized()
{
Console.WriteLine("Chart.OnInitialized()");

//
Подписка на события сервиса (ТОЛЬКО рендер), генератор не трогаем здесь
Ticker.OnTick += HandleTick;

//
Если генератор уже работает (нажал Start ранее и ушёл со страницы),
//
сразу подписаны и начнём регулярно перерисовываться.
//
Сделаем первичный рендер текущего буфера.
RenderChart(Ticker.Current);

// Если генератор не работает — просто показываем текущие данные без таймера.
}

private void HandleTick()
{
// Тики приходят из фонового таймера: обновляем график и просим UI перерисоваться.
RenderChart(Ticker.Current);
InvokeAsync(StateHasChanged);
}

private void RenderChart(double[] data)
{
Console.WriteLine("RenderChart()");
var chart = new ChartDirector.XYChart(250, 250);
chart.setPlotArea(30, 20, 200, 200);
chart.addBarLayer(data);
chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

byte[] svgBytes = chart.makeChart(ChartDirector.Chart.SVG);
svg = System.Text.Encoding.UTF8.GetString(svgBytes);
}

private void OnStart()
{
Console.WriteLine("OnStart()");
// Стартуем генератор, если он ещё не запущен
if (!Ticker.IsRunning)
Ticker.Start();

// Сделаем моментальный рендер текущего буфера, не дожидаясь первой секунды
RenderChart(Ticker.Current);
StateHasChanged();
}

private void OnStop()
{
Console.WriteLine("OnStop()");
// Останавливаем генератор и рендеринг (тики перестанут приходить)
if (Ticker.IsRunning)
Ticker.Stop();
}

public void Dispose()
{
// При уходе со страницы: отключаемся только от событий рендера,
// генератор не трогаем — он продолжит работать, если пользователь нажал Start.
Ticker.OnTick -= HandleTick;
}
}
-----
public sealed class DataTicker : IDisposable
{
private const int MinValue = 80;
private const int MaxValue = 181;
private const int BufferSize = 5;

private readonly object _sync = new();
private readonly Random _rng = new();
private readonly System.Timers.Timer _timer;
private bool _disposed;

public bool IsRunning { get; private set; }

public double[] Current { get; private set; } = new double[BufferSize] { 100, 100, 100, 100, 100 };

public event Action? OnTick;

public DataTicker()
{
_timer = new System.Timers.Timer(1000);
_timer.Elapsed += (_, __) => Tick();
_timer.AutoReset = true;
IsRunning = false;
}

public void Start()
{
Console.WriteLine("Ticker.Start()");
if (_disposed) return;

lock (_sync)
{
if (!IsRunning)
{
IsRunning = true;
if (!_timer.Enabled) _timer.Start();
}
}
}

public void Stop()
{
Console.WriteLine("Ticker.Stop()");
if (_disposed) return;

lock (_sync)
{
if (IsRunning)
{
IsRunning = false;
if (_timer.Enabled) _timer.Stop();
}
}
}

private void Tick()
{
if (_disposed) return;

var next = new double[BufferSize];
for (int i = 0; i < BufferSize; i++)
next[i] = _rng.Next(MinValue, MaxValue);

lock (_sync)
{
Current = next;
}

try
{
OnTick?.Invoke();
}
catch
{
//
игнорируем исключения подписчиков
}
}

public void Dispose()
{
Console.WriteLine("Ticker.Dispose()");
if (_disposed) return;
_disposed = true;
_timer.Dispose();
Console.WriteLine("Ticker.Dispose() Complete;");
}
}
---------
Вот весь код.

Спасибо за код — он почти правильный. Вижу две вероятные причины, почему нажатие Start визуально «ничего не делает»:

  1. Обработчики кнопок не привязаны к методам
    В разметке у вас:
  • кнопки вызывают OnStart/OnStop
  • но в первом блоке @code объявлены методы Start/Stop, которые нигде не используются
  • реальные OnStart/OnStop объявлены во втором блоке @code

Blazor поддерживает несколько @code-блоков, но это легко вводит в заблуждение и можно случайно путать методы/состояния. Если где-то осталась старая разметка с @onclick="Start", кнопка не вызовет OnStart. Убедитесь, что в разметке именно:

  • @onclick="OnStart"
  • @onclick="OnStop"

И лучше оставьте один @code-блок, удалив неиспользуемые методы Start/Stop.

  1. Поток таймера и потокобезопасность события
    Вы вызываете OnTick из System.Timers.Timer, который срабатывает на пуле потоков. В Chart компоненте вы корректно делаете InvokeAsync(StateHasChanged) — это хорошо. Но ещё два момента:
  • Проверка отключения события при Dispose: у вас есть отписка Ticker.OnTick -= HandleTick; — ок.
  • Возможна гонка при чтении Ticker.IsRunning/Current в компоненте. Для чтения массива это ок, т.к. вы присваиваете ссылку атомарно, но хорошо бы зафиксировать.

Что обязательно проверьте/исправьте

  • Оставьте единственный @code-блок и используйте ровно эти обработчики:

@code {

    private string? svg;

 

    protected override void OnInitialized()

    {

        Console.WriteLine("Chart.OnInitialized()");

        Ticker.OnTick += HandleTick;

        RenderChart(Ticker.Current);

    }

 

    private void HandleTick()

    {

        RenderChart(Ticker.Current);

        InvokeAsync(StateHasChanged);

    }

 

    private void RenderChart(double[] data)

    {

        Console.WriteLine("RenderChart()");

        var chart = new ChartDirector.XYChart(250, 250);

        chart.setPlotArea(30, 20, 200, 200);

        chart.addBarLayer(data);

        chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

        var svgBytes = chart.makeChart(ChartDirector.Chart.SVG);

        svg = System.Text.Encoding.UTF8.GetString(svgBytes);

    }

 

    private void OnStart()

    {

        Console.WriteLine("OnStart()");

        if (!Ticker.IsRunning)

            Ticker.Start();

 

        RenderChart(Ticker.Current);

        StateHasChanged();

    }

 

    private void OnStop()

    {

        Console.WriteLine("OnStop()");

        if (Ticker.IsRunning)

            Ticker.Stop();

    }

 

    public void Dispose()

    {

        Ticker.OnTick -= HandleTick;

    }

}

  • Убедитесь, что разметка кнопок совпадает:

<button @onclick="OnStart" disabled="@Ticker.IsRunning">Start</button>

<button @onclick="OnStop" disabled="@(!Ticker.IsRunning)">Stop</button>

  • Проверьте, действительно ли рендерится SVG. Если библиотека ChartDirector не найдена/не работает на сервере, svg будет null или пуста. Вы это не логируете. Добавьте проверку:

var svgBytes = chart.makeChart(ChartDirector.Chart.SVG);

if (svgBytes is null || svgBytes.Length == 0)

    Console.WriteLine("SVG generation returned empty result");

  • Проверьте вывод в консоли сервера. Должны быть видны:
    • Chart.OnInitialized()
    • OnStart()
    • Ticker.Start()
    • регулярные RenderChart() каждую секунду

Если OnStart() не выводится — событие кнопки не срабатывает. Это бывает, если компонент не интерактивный. Вы включили интерактивность серверную через .AddInteractiveServerComponents() и .AddInteractiveServerRenderMode(), но нужно ещё назначить render mode для компонента/страницы. В App.razor или в странице используйте InteractiveServerRenderMode, например:

  • В App.razor:

<Router AppAssembly="@typeof(App).Assembly">

    <Found Context="routeData">

        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />

    </Found>

    <NotFound>

        <PageTitle>Not found</PageTitle>

        <LayoutView Layout="@typeof(MainLayout)">

            <p>Sorry, there's nothing at this address.</p>

        </LayoutView>

    </NotFound>

</Router>

 

<HeadOutlet />

И в Program.cs у вас уже есть:

  • builder.Services.AddRazorComponents().AddInteractiveServerComponents();
  • app.MapRazorComponents<app>().AddInteractiveServerRenderMode();

Этого достаточно для всего приложения, если вы не переопределяете рендер-мод на странице. Но если страница Chart помечена как статическая, кнопки не будут работать. Проверьте, чтобы в Chart.razor был задан интерактивный режим (если вы используете per-component):

@rendermode InteractiveServer

Или глобально — как у вас — тогда не нужно. Если не работает, попробуйте явно добавить @rendermode InteractiveServer в Chart.razor.

  • Убедитесь, что нет JS-ошибок в консоли браузера. Любая ошибка в Blazor Server соединении (SignalR) сделает кнопки «мертвыми». Должно быть активное подключение к /_blazor. Проверьте, что app.UseStaticFiles() не нужен: вы используете MapStaticAssets(), это корректно для RCL. Если у вас нет статики, это нормально.
  • Таймер System.Timers.Timer в ASP.NET может быть сборщиком мусора собран, если нет ссылки. У вас поле _timer — это инстанс в singleton, так что GC его не соберёт. Всё ок.

Маленькие улучшения безопасности потока

  • Защитите чтение Current, чтобы не получить полусостояние при одновременном чтении/записи. Сейчас вы присваиваете новую ссылку атомарно, но можно скопировать ссылку под lock:

В DataTicker:

public double[] Snapshot()

{

    lock (_sync)

        return Current;

}

И в компоненте вызывайте RenderChart(Ticker.Snapshot()).

Итоговый чек-лист

  • В Chart.razor есть @onclick="OnStart" и метод OnStart существует.
  • Один @code-блок, без неиспользуемых Start/Stop.
  • @rendermode InteractiveServer (если требуется явно).
  • В консоли видите OnStart() и Ticker.Start().
  • Нет ошибок в браузере и активен SignalR канал /_blazor.
  • ChartDirector генерирует непустой svg.

Если после этих шагов кнопка все ещё «ничего не делает», пришлите:

  • содержимое App.razor,
  • Layout и _Host/Main шаблон (если есть),
  • логи браузера (консоль) и сервера на клике Start.

GPT-5

ВЫ оказались правы надо было "Просто добавить воды"
Вот эту строку @rendermode InteractiveServer

Отлично! Рад, что помогло. @rendermode InteractiveServer действительно часто оказывается «той самой водой» для интерактивных компонентов в Blazor.

Если будете выносить компонент на другие страницы или в отдельный проект:

  • Проверьте, что в _Host/MainLayout включены скрипты Blazor и есть <component ... render-mode="InteractiveServer"> при серверном рендеринге.
  • Убедитесь, что SignalR endpoint /_blazor доступен и нет блокировок CORS/проксей.
  • Для стабильности интерактива в .NET 8/9 можно добавить в Program.cs: app.MapBlazorHub();

Если что-то ещё понадобится — присылайте фрагменты, посмотрим вместе.

GPT-5

ТАк что же по Вашему лучше
- Выбирать современный Проект Blazor Web App и постоянно добавлять эту волшебную строку
- Выбирать старый проект Blazor Server App и не думать больше никогда об этой волшебной строке.
---
Мы ведь могли из-за этой одной строки весь код сломать и разукрасить Conosle.WrieLine
?

Короткий ответ: для новых проектов лучше Blazor Web App. Но осознанно.

Почему:

  • Blazor Web App = “универсальный” шаблон .NET 8/9. Он поддерживает статический рендеринг, SSR, интерактивность (Server/WebAssembly/Auto) и смешанные страницы. Гибкость выше, но нужно явно задавать режим интерактивности: через директиву @rendermode в компоненте или через параметр render-mode при встраивании компонента. Это не баг, а договор: вы сами управляете, где нужен интерактив.
  • Blazor Server App = классический шаблон “всё интерактивно по умолчанию через SignalR”. Меньше движущихся частей, меньше шансов “забыть строку”. Но меньше вариативности и сложнее мигрировать к гибридным сценариям (например, частично статический рендеринг, частично WASM).

Как выбрать на практике:

  • Берите Blazor Server App, если:
    • Нужен быстрый старт, команда не знакома с новыми режимами.
    • Приложение полностью интерактивное, без сильных требований к SEO/статике.
    • Вы не планируете смешивать режимы или выносить части в WASM.
  • Берите Blazor Web App, если:
    • Хотите современные возможности: SSR + интерактивные “островки”, Auto/Server/WASM на выбор.
    • Важны производительность первой загрузки, SEO и частично статический контент.
    • Планируете эволюцию архитектуры без смены шаблона.

Как не “сломать всё из‑за одной строки”:

  • На уровне страницы/компонента: добавьте @rendermode InteractiveServer в компоненты, где это важно, и вынесите это в шаблон/базовый компонент, чтобы не дублировать.
  • Глобально: если большинство компонентов должны быть интерактивными, используйте RenderMode по умолчанию в местах встраивания:
    • В Razor pages/layout: <component type="typeof(App)" render-mode="InteractiveServer" />
    • Либо создайте свой Layout/Host компонент, который всегда задаёт нужный render-mode.
  • Проверка на CI: добавьте простой тест/анализатор, который ищет ключевые интерактивные компоненты без @rendermode и сигнализирует в PR.
  • Логи: в Dev окружении логируйте отсутствие интерактивности (например, баннер при отсутствии circuit). Так вы быстрее заметите забытый render-mode, а не через Console.WriteLine в проде.

Итоговая рекомендация:

  • Новые проекты — Blazor Web App, с дисциплиной в управлении render-mode и парой “страховочных” практик выше.
  • Если команде важна простота и всё — 100% интерактивный сервер — берите Blazor Server App, это нормально.

Если покажете структуру вашего приложения (где интерактив обязателен, где нет), предложу конкретную схему: глобальный render-mode + список компонентов-исключений, чтобы не размножать “волшебную строку”.

GPT-5

Я сейчас сюда выложу окончательный код в формате нескольких частей.
Поэтому не отвечай до тех пр пока я не дам команду.
Я буду выкладывать код по Частям.

Понял. Жду ваши части кода. Не буду отвечать, пока вы не дадите команду.

GPT-5

using BlazorWebApp05.Components;

namespace BlazorWebApp05
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();

builder.Services.AddSingleton<DataTicker>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see `https://aka.ms/aspnetcore-hsts.`
app.UseHsts();
}

app.UseHttpsRedirection();

app.UseAntiforgery();

app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

app.Run();
}
}
}
-------

(Ок, принял часть кода. Жду следующие части. Отвечу после вашей команды.)

GPT-5

public sealed class DataTicker : IDisposable
{
private const int MinValue = 80;
private const int MaxValue = 181;
private const int BufferSize = 5;

private readonly object _sync = new();
private readonly Random _rng = new();
private readonly System.Timers.Timer _timer;
private bool _disposed;

public bool IsRunning { get; private set; }

public double[] Current { get; private set; } = new double[BufferSize] { 100, 100, 100, 100, 100 };

public event Action? OnTick;

public DataTicker()
{
_timer = new System.Timers.Timer(1000);
_timer.Elapsed += (_, __) => Tick();
_timer.AutoReset = true;
IsRunning = false;
}

public void Start()
{
Console.WriteLine("Ticker.Start()");
if (_disposed) return;

lock (_sync)
{
if (!IsRunning)
{
IsRunning = true;
if (!_timer.Enabled) _timer.Start();
}
}
}

public void Stop()
{
Console.WriteLine("Ticker.Stop()");
if (_disposed) return;

lock (_sync)
{
if (IsRunning)
{
IsRunning = false;
if (_timer.Enabled) _timer.Stop();
}
}
}

private void Tick()
{
if (_disposed) return;

var next = new double[BufferSize];
for (int i = 0; i < BufferSize; i++)
next[i] = _rng.Next(MinValue, MaxValue);

lock (_sync)
{
Current = next;
}

try
{
OnTick?.Invoke();
}
catch
{
//
игнорируем исключения подписчиков
}
}

public void Dispose()
{
Console.WriteLine("Ticker.Dispose()");
if (_disposed) return;
_disposed = true;
_timer.Dispose();
Console.WriteLine("Ticker.Dispose() Complete;");
}
}
----

Принял второй фрагмент с DataTicker. Готов помочь: хотите, чтобы я

  • проверил потокобезопасность/утечки и предложил улучшения,
  • показал пример компонента, который подписывается на OnTick и отображает данные (включая отписку/Dispose),
  • добавил кнопку старт/стоп и индикатор состояния,
  • заменил System.Timers.Timer на PeriodicTimer/BackgroundService,
  • сделал трансляцию в реальном времени через SignalR?

Сформулируйте, что нужно.

GPT-5

<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">BlazorWebApp05</a>
</div>
</div>

<input type="checkbox" title="Navigation menu" class="navbar-toggler" />

<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>

<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>

<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="chart">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Chart
</NavLink>
</div>
</nav>
</div>

Принял разметку нав-меню для Blazor (вероятно, NavMenu.razor). Подскажите, что нужно сделать:

  • добавить пункт для страницы DataTicker/реального времени,
  • починить автозакрытие меню по клику/клавиатуре,
  • сделать активные иконки/ARIA-доступность,
  • адаптировать под Bootstrap 5 (offcanvas/collapse),
  • вынести в компонент и добавить параметры,
  • переключать темы (dark/light),
  • локализация заголовков.

Если хотите, могу сразу предложить улучшенную версию с Bootstrap collapse и правильным NavLinkMatch.

GPT-5

@page "/chart"
@inject DataTicker Ticker
@rendermode InteractiveServer
@implements IDisposable


<h3>Realtime SVG Chart</h3>

<div style="margin-bottom:8px;">
<button class="btn btn-primary" @onclick="Start" disabled="@Ticker.IsRunning">Start</button>
<button class="btn btn-primary" @onclick="Stop" disabled="@(!Ticker.IsRunning)">Stop</button>
</div>

@if (svg is null)
{
<p>Loading...</p>
}
else
{
@((MarkupString)svg)
}

@code {
private string? svg;

protected override void OnInitialized()
{
Console.WriteLine("Chart.OnInitialized()");

// Подписка на события сервиса
Ticker.OnTick += HandleTick;

// Первичный рендер текущего буфера
RenderChart(Ticker.Current);
}

private void HandleTick()
{
// Тики приходят из фонового таймера: обновляем график и просим UI перерисоваться.
RenderChart(Ticker.Current);
InvokeAsync(StateHasChanged);
}

private void RenderChart(double[] data)
{
Console.WriteLine($"RenderChart() with data: {string.Join(", ", data)}");

try
{
var chart = new ChartDirector.XYChart(250, 250);
chart.setPlotArea(30, 20, 200, 200);
chart.addBarLayer(data);
chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

byte[] svgBytes = chart.makeChart(ChartDirector.Chart.SVG);
svg = System.Text.Encoding.UTF8.GetString(svgBytes);
}
catch (Exception ex)
{
Console.WriteLine($"Error in RenderChart: {ex.Message}");
svg = $"<p>Error rendering chart: {ex.Message}</p>";
}
}

private void Start()
{
Console.WriteLine("Start() called");

// Стартуем генератор, если он ещё не запущен
if (!Ticker.IsRunning)
{
Ticker.Start();
Console.WriteLine("Ticker started");
}

// Сделаем моментальный рендер текущего буфера
RenderChart(Ticker.Current);
StateHasChanged();
}

private void Stop()
{
Console.WriteLine("Stop() called");

// Останавливаем генератор
if (Ticker.IsRunning)
{
Ticker.Stop();
Console.WriteLine("Ticker stopped");
}
}

public void Dispose()
{
Console.WriteLine("Chart.Dispose()");

// Отписываемся от событий
if (Ticker != null)
{
Ticker.OnTick -= HandleTick;
}
}
}
--

Nice setup. A few practical improvements to make this realtime Blazor SVG chart snappier, safer, and easier to maintain:

  1. Thread-safety and re-entrancy
  • If Ticker raises OnTick from a background thread (it likely does), guard against overlapping HandleTick calls.
  • Also guard against disposal during an in-flight tick.

Example:

@code {

    private string? svg;

    private int _rendering; // 0/1

    private bool _disposed;

 

    protected override void OnInitialized()

    {

        Console.WriteLine("Chart.OnInitialized()");

        Ticker.OnTick += HandleTick;

        RenderChart(Ticker.Current);

    }

 

    private async void HandleTick()

    {

        if (_disposed) return;

 

        // prevent overlapping renders

        if (Interlocked.Exchange(ref _rendering, 1) == 1) return;

        try

        {

            RenderChart(Ticker.Current);

            await InvokeAsync(() =>

            {

                if (!_disposed) StateHasChanged();

            });

        }

        finally

        {

            Interlocked.Exchange(ref _rendering, 0);

        }

    }

 

    private void RenderChart(double[] data)

    {

        Console.WriteLine($"RenderChart() with data: {string.Join(", ", data)}");

 

        try

        {

            var chart = new ChartDirector.XYChart(250, 250);

            chart.setPlotArea(30, 20, 200, 200);

            chart.addBarLayer(data);

            chart.xAxis().setLabels(new[] { "Mon", "Tue", "Wed", "Thu", "Fri" });

 

            byte[] svgBytes = chart.makeChart(ChartDirector.Chart.SVG);

            svg = System.Text.Encoding.UTF8.GetString(svgBytes);

        }

        catch (Exception ex)

        {

            Console.WriteLine($"Error in RenderChart: {ex}");

            svg = $"<p>Error rendering chart: {System.Net.WebUtility.HtmlEncode(ex.Message)}</p>";

        }

    }

 

    private void Start()

    {

        Console.WriteLine("Start() called");

        if (!Ticker.IsRunning)

        {

            Ticker.Start();

            Console.WriteLine("Ticker started");

        }

        RenderChart(Ticker.Current);

        StateHasChanged();

    }

 

    private void Stop()

    {

        Console.WriteLine("Stop() called");

        if (Ticker.IsRunning)

        {

            Ticker.Stop();

            Console.WriteLine("Ticker stopped");

        }

    }

 

    public void Dispose()

    {

        Console.WriteLine("Chart.Dispose()");

        _disposed = true;

        if (Ticker != null)

        {

            Ticker.OnTick -= HandleTick;

        }

    }

}

  1. Prevent unnecessary console noise in production
  • Consider wrapping Console.WriteLine with a logger or conditional compilation so production logs aren’t flooded.
  1. Accessibility and progressive enhancement
  • Provide a container for the SVG with role and accessible name. If SVG fails, the error paragraph is shown already, which is good.

Example:

<h3 id="chart-title">Realtime SVG Chart</h3>

<div role="img" aria-labelledby="chart-title">

    @if (svg is null)

    {

        <p>Loading...</p>

    }

    else

    {

        @((MarkupString)svg)

    }

</div>

  1. Avoid unnecessary StateHasChanged calls
  • In Start(), you render and then call StateHasChanged() once; that’s fine.
  • In HandleTick(), you already marshal to UI once per tick; the guard added above prevents overlapping renders.
  1. Dispose robustness
  • The _disposed flag plus unsubscription avoids a race if a tick arrives as the component is tearing down.
  1. Optional: use OnInitializedAsync and async Start
  • If you plan to do async work later (loading data, etc.), switch to OnInitializedAsync and make Start/Stop async-safe.
  1. Optional: move chart creation to a service
  • If you want to test or swap chart libraries, extract RenderChart into an IChartRenderer service returning SVG string from double[].
  1. Optional: throttle rendering
  • If ticks are very frequent, add a simple time-based throttle or coalescing to avoid excessive UI updates.

Example throttle:

private long _lastUpdateTicks;

private readonly TimeSpan _minInterval = TimeSpan.FromMilliseconds(100);

 

private async void HandleTick()

{

    if (_disposed) return;

 

    var now = Stopwatch.GetTimestamp();

    var elapsed = TimeSpan.FromSeconds((now - _lastUpdateTicks) / (double)Stopwatch.Frequency);

    if (elapsed < _minInterval) return;

    _lastUpdateTicks = now;

 

    if (Interlocked.Exchange(ref _rendering, 1) == 1) return;

    try

    {

        RenderChart(Ticker.Current);

        await InvokeAsync(() => { if (!_disposed) StateHasChanged(); });

    }

    finally

    {

        Interlocked.Exchange(ref _rendering, 0);

    }

}

  1. UI polish
  • Disable Start/Stop buttons while rendering to avoid user-triggered overlaps, or at least reflect Ticker.IsRunning state as you already do.
  • Add aria-pressed to communicate toggle-like state:

<button class="btn btn-primary" @onclick="Start" disabled="@Ticker.IsRunning" aria-pressed="@Ticker.IsRunning">Start</button>

<button class="btn btn-secondary" @onclick="Stop" disabled="@(!Ticker.IsRunning)" aria-pressed="@(!Ticker.IsRunning)">Stop</button>

If you share your DataTicker implementation, I can suggest safe event invocation patterns and timer configuration (e.g., System.Threading.PeriodicTimer with async loop and try/catch around callbacks).

GPT-5

Напиши все то , что ты сейчас написал по русски. Что это было ? Анализ кода ?

Похоже, в предыдущем сообщении у меня произошел сбой, и вывод получился обрезанным и неполным. Судя по фрагментам, это была техническая рекомендация по улучшению кода UI (вероятно, Blazor/ Razor-компонент) с акцентом на безопасный рендеринг и полировку интерфейса. Ниже — полная русская версия той части, что была видна, плюс связный контекст.

  1. Безопасный рендеринг и синхронизация
    Идея: при частых обновлениях UI (например, из таймера/тикера) важно избежать конкурентных вызовов StateHasChanged и наложения рендеров. Один из подходов — использовать флаг рендеринга с Interlocked:

private int _rendering = 0;

 

private void RequestRender()

{

    if (Interlocked.CompareExchange(ref _rendering, 1, 0) != 0)

        return;

 

    try

    {

        InvokeAsync(() =>

        {

            if (!Disposed)

            {

                StateHasChanged();

            }

        });

    }

    finally

    {

        Interlocked.Exchange(ref _rendering, 0);

    }

}

Смысл: если рендер уже идет, второй не запускаем; после завершения сбрасываем флаг. Вызов через InvokeAsync гарантирует выполнение на корректном синхронизированном контексте UI.

  1. Полировка UI
  • На время рендеринга/работы таймера лучше блокировать кнопки Start/Stop, чтобы избежать наложений, или хотя бы отражать текущее состояние Ticker.IsRunning, как вы уже делаете.
  • Добавить aria-pressed, чтобы ассистивные технологии корректно воспринимали «переключательное» состояние кнопок.

Пример:

<button class="btn btn-primary"

        @onclick="Start"

        disabled="@Ticker.IsRunning"

        aria-pressed="@Ticker.IsRunning">

    Start

</button>

 

<button class="btn btn-secondary"

        @onclick="Stop"

        disabled="@(!Ticker.IsRunning)"

        aria-pressed="@(!Ticker.IsRunning)">

    Stop

</button>

Если вы поделитесь реализацией DataTicker, я подскажу безопасные паттерны вызова событий и настройки таймера (например, System.Threading.PeriodicTimer с асинхронным циклом и try/catch вокруг колбэков), чтобы исключить гонки и пропуски рендеров.

Ответ на ваш вопрос: да, это был анализ и рекомендации по коду — как стабилизировать обновления UI и улучшить доступность/поведение кнопок.

GPT-5

public sealed class DataTicker : IDisposable
{
private const int MinValue = 80;
private const int MaxValue = 181;
private const int BufferSize = 5;

private readonly object _sync = new();
private readonly Random _rng = new();
private readonly System.Timers.Timer _timer;
private bool _disposed;

public bool IsRunning { get; private set; }

public double[] Current { get; private set; } = new double[BufferSize] { 100, 100, 100, 100, 100 };

public event Action? OnTick;

public DataTicker()
{
_timer = new System.Timers.Timer(1000);
_timer.Elapsed += (_, __) => Tick();
_timer.AutoReset = true;
IsRunning = false;
}

public void Start()
{
Console.WriteLine("Ticker.Start()");
if (_disposed) return;

lock (_sync)
{
if (!IsRunning)
{
IsRunning = true;
if (!_timer.Enabled) _timer.Start();
}
}
}

public void Stop()
{
Console.WriteLine("Ticker.Stop()");
if (_disposed) return;

lock (_sync)
{
if (IsRunning)
{
IsRunning = false;
if (_timer.Enabled) _timer.Stop();
}
}
}

private void Tick()
{
if (_disposed) return;

var next = new double[BufferSize];
for (int i = 0; i < BufferSize; i++)
next[i] = _rng.Next(MinValue, MaxValue);

lock (_sync)
{
Current = next;
}

try
{
OnTick?.Invoke();
}
catch
{
//
игнорируем исключения подписчиков
}
}

public void Dispose()
{
Console.WriteLine("Ticker.Dispose()");
if (_disposed) return;
_disposed = true;
_timer.Dispose();
Console.WriteLine("Ticker.Dispose() Complete;");
}
}

Ниже разбор вашего DataTicker и рекомендации для стабильной работы в UI (Blazor/WPF/WinForms) и корректной многопоточности.

Что уже хорошо

  • Отдельный таймер и флаг IsRunning.
  • Потокобезопасное обновление Current через lock.
  • Безопасный вызов подписчиков (try/catch).

Основные проблемы

  1. System.Timers.Timer вызывает Elapsed на пул-потоках и может перезапускаться, пока обработчик ещё исполняется (reentrancy), несмотря на AutoReset=true. Это чревато гонками и наложением Tick.
  2. Random не потокобезопасен. Сейчас Tick вызывается из одного таймерного потока, но при потенциальной реэнтрантности это риск.
  3. OnTick вызывается под подписчиком без маршалинга в UI-поток. В Blazor Server/WPF/WinForms потребуется диспатчер.
  4. IsRunning и Enabled/Start/Stop в разных местах: при гонке возможен разнобой статуса и фактического состояния таймера.
  5. Dispose не отписывает обработчик Elapsed и не останавливает таймер перед Dispose (могут прилететь события после Dispose).
  6. Текущее копирование массива безопасно, но подписчики могут читать Current без барьера видимости вне lock.

Варианты решений

Вариант A: Перейти на PeriodicTimer + асинхронный цикл

  • Исключает реэнтрантность, проще остановка/Dispose, лучше контроль жизненного цикла.

Пример (универсальный, безопасный):

public sealed class DataTicker : IAsyncDisposable

{

    private const int MinValue = 80;

    private const int MaxValue = 181;

    private const int BufferSize = 5;

 

    private readonly object _sync = new();

    private readonly Random _rng = new();

    private PeriodicTimer? _timer;

    private CancellationTokenSource? _cts;

    private Task? _loopTask;

    private bool _disposed;

 

    public bool IsRunning { get; private set; }

    public double[] Current { get; private set; } = new double[BufferSize] { 100, 100, 100, 100, 100 };

 

    public event Action? OnTick;

 

    public void Start(TimeSpan? period = null)

    {

        if (_disposed) return;

        lock (_sync)

        {

            if (IsRunning) return;

            IsRunning = true;

            _cts = new CancellationTokenSource();

            _timer = new PeriodicTimer(period ?? TimeSpan.FromSeconds(1));

            _loopTask = RunAsync(_cts.Token);

        }

    }

 

    public void Stop()

    {

        if (_disposed) return;

        CancellationTokenSource? cts;

        Task? loop;

        lock (_sync)

        {

            if (!IsRunning) return;

            IsRunning = false;

            cts = _cts; _cts = null;

            loop = _loopTask; _loopTask = null;

        }

        try { cts?.Cancel(); } catch {}

        try { loop?.Wait(); } catch {}

        lock (_sync)

        {

            _timer?.Dispose();

            _timer = null;

            cts?.Dispose();

        }

    }

 

    private async Task RunAsync(CancellationToken ct)

    {

        try

        {

            while (await _timer!.WaitForNextTickAsync(ct).ConfigureAwait(false))

            {

                Tick();

            }

        }

        catch (OperationCanceledException) { }

        catch (ObjectDisposedException) { }

    }

 

    private void Tick()

    {

        if (_disposed) return;

 

        // Генерация данных

        var rng = _rng; // локальная ссылка

        var next = new double[BufferSize];

        for (int i = 0; i < BufferSize; i++)

            next[i] = rng.Next(MinValue, MaxValue);

 

        // Публикация снапшота

        lock (_sync)

        {

            Current = next; // заменяем ссылку атомарно под lock

        }

 

        try

        {

            OnTick?.Invoke();

        }

        catch

        {

            // Игнор исключений подписчиков

        }

    }

 

    public async ValueTask DisposeAsync()

    {

        if (_disposed) return;

        _disposed = true;

 

        Stop();

 

        // На всякий случай дождаться текущей задачи

        if (_loopTask is { } t)

        {

            try { await t.ConfigureAwait(false); } catch {}

        }

    }

}

Особенности:

  • Нет реэнтрантности: следующий тик не начнётся, пока не завершён текущий.
  • Stop аккуратно отменяет цикл и освобождает ресурсы.
  • Current публикуется как новый массив — подписчики могут читать без lock, если получают ссылку после OnTick. Для строгой модели памяти в .NET достаточно публикации через ссылку, но lock гарантирует порядок записи.

Вариант B: Оставить System.Timers.Timer, но защититься от реэнтрантности

  • Отключаем таймер при входе в Tick, включаем после.
  • Отписываемся и останавливаем в Dispose.

public sealed class DataTicker : IDisposable

{

    // ... поля как у вас

    private int _inTick; // 0/1 флаг выполнения

 

    public DataTicker()

    {

        _timer = new System.Timers.Timer(1000);

        _timer.AutoReset = false; // важно

        _timer.Elapsed += OnElapsed;

    }

 

    private void OnElapsed(object? sender, System.Timers.ElapsedEventArgs e)

    {

        if (_disposed) return;

 

        if (Interlocked.Exchange(ref _inTick, 1) == 0)

        {

            try

            {

                Tick();

            }

            finally

            {

                Interlocked.Exchange(ref _inTick, 0);

            }

        }

        // Перезапускаем только если ещё Running

        lock (_sync)

        {

            if (IsRunning && !_disposed)

                _timer.Start();

        }

    }

 

    public void Start()

    {

        if (_disposed) return;

        lock (_sync)

        {

            if (IsRunning) return;

            IsRunning = true;

            _timer.Interval = 1000;

            _timer.Start();

        }

    }

 

    public void Stop()

    {

        if (_disposed) return;

        lock (_sync)

        {

            if (!IsRunning) return;

            IsRunning = false;

            _timer.Stop();

        }

    }

 

    private void Tick()

    {

        if (_disposed) return;

 

        var next = new double[BufferSize];

        for (int i = 0; i < BufferSize; i++)

            next[i] = _rng.Next(MinValue, MaxValue);

 

        lock (_sync)

        {

            Current = next;

        }

 

        try { OnTick?.Invoke(); } catch {}

    }

 

    public void Dispose()

    {

        if (_disposed) return;

        _disposed = true;

 

        lock (_sync)

        {

            IsRunning = false;

            _timer.Stop();

            _timer.Elapsed -= OnElapsed;

            _timer.Dispose();

        }

    }

}

Дополнительные рекомендации

  • Если это Blazor:
    • В компоненте подписывайтесь на OnTick и вызывайте InvokeAsync(StateHasChanged), а не напрямую StateHasChanged, чтобы маршалить в правильный контекст и не ловить исключения:

protected override void OnInitialized()

    {

        Ticker.OnTick += async () =>

        {

            try { await InvokeAsync(StateHasChanged); } catch {}

        };

    }

  • Кнопки Start/Stop: делайте привязку disabled и aria-pressed к IsRunning, как вы уже показали ранее.
  • Если это WPF/WinForms:
    • В OnTick используйте SynchronizationContext.Post или Dispatcher.InvokeAsync для обновления UI.
  • Random:
    • При единственном фон-потоке дополнительных мер не требуется. Если возможен вызов из нескольких потоков — используйте RandomNumberGenerator или ThreadLocal<Random>. С .NET 6+ можно применять Random.Shared.
  • Видимость Current:
    • Вы уже заменяете ссылку на новый массив. Если подписчики читают сразу в обработчике OnTick, этого достаточно. Для абсолютно строгой семантики можно пометить Current как volatile или использовать Volatile.Write(ref Current, next) и Volatile.Read(ref Current) при чтении.
  • Исключения:
    • Вы гасите исключения подписчиков — ок для тикеров. Если нужно логирование, добавьте лог колбэков.
  • Настройка периода:
    • Сделайте период параметром конструктора или Start, чтобы легче управлять частотой.

Итог: самый простой и надёжный путь — перейти на PeriodicTimer с асинхронным циклом (Вариант A). Если остаётесь на System.Timers.Timer, обязательно выключайте AutoReset и защищайтесь от реэнтрантности, как в Варианте B, и корректно освобождайте таймер в Dispose.