Работа с периодами может быть запутанной. Представьте, что у вас бухгалтерское приложение. И вам нужно получить периоды, когда сотрудник работал по графику “2 через 2” до индексации зарплаты. При этом нужно учитывать отпуска, смены графиков работы, увольнения/восстановления, переходы в другие отделы и прочие кадровые мероприятия. Эта информация хранится в виде приказов, у которых есть “Дата начала действия” и “Дата конца”, т.е. у вас есть периоды времени, с которыми нужно производить операции.
Например найти пересечение всех интервалов:
2 5 7 9
|--------------| |---------|
0 3 4 6 7 10
|--------------| |---------| |--------------|
1 4 5 8
|--------------| |--------------|
Result
2 3 7 8
|----| |----|
// Для упрощения восприятия даты заменены на числа
Для решения подобных задач и предназначена анонсируемая утилита.
Работать с периодами в визуальном представлении гораздо проще, поэтому для тестирования (набор тестов тоже есть) и документации я сделал простенькую генерацию ASCII изображений, как показано выше.
Оказалось, что с помощью полученной ASCII генерилки можно рисовать еще и графики y=f(x).
Рис 1. Демонстрация генерации ASCII графиков на Blazor WebAssembly. Можно указывать несколько произвольных функции зависимости y от x.
Анонс утилиты для работы с интервалами IntervalUtility
GitHub, NuGet, интерактивное demo на Blazor WebAssembly.
Ниже пара примеров работы утилиты. Больше примеров на GitHub.
Найти пересечения коллекций интервалов
var arrayOfArrays = new[] {
new[] { new Interval<int>(2,5), new Interval<int>(7, 9) },
new[] { new Interval<int>(0,3), new Interval<int>(4, 6),
new Interval<int>(7, 10) },
new[] { new Interval<int>(1,4), new Interval<int>(5, 8) },
};
var intervalUtil = new IntervalUtil();
var res = intervalUtil.Intersections(arrayOfArrays);
// => [2,3], [7,8]
2 5 7 9
|--------------| |---------|
0 3 4 6 7 10
|--------------| |---------| |--------------|
1 4 5 8
|--------------| |--------------|
Result
2 3 7 8
|----| |----|
Вычитание коллекции интервалов
var intervalsA = new[] { new Interval<int>(1, 5), new Interval<int>(7, 10) };
var intervalsB = new[] { new Interval<int>(0, 2), new Interval<int>(3, 5),
new Interval<int>(8, 9) };
var intervalUtil = new IntervalUtil();
var res = intervalUtil.Exclude(intervalsA, intervalsB);
// => [2,3], [7,8], [9,10]
1 5 7 10
|-------------------| |--------------|
0 2 3 5 8 9
|---------| |---------| |----|
Result
2 3 7 8 9 10
|----| |----| |----|
На этом полезная часть статьи заканчивается. Дальше будет способ генерации бесполезных ASCII графиков.
Генератор ASCII графиков
Интерактивное demo на Blazor WebAssembly.
В описании есть вставки “Выражаясь наукообразно”. Эти вставки содержат указание на используемый прием/паттерн. По задумке, вставки “Выражаясь наукообразно”, должны показать, что “умные программистские” термины - это просто краткие названия несложных идей.
Основная идея: “рисовать ASCII” блоками фиксированного размера, по аналогии с пикселями
Нужен способ отображения символов (или блоков символов). Пусть “изображение” строится блоками одинаковой длины. Так удобнее. Можно сказать что один блок - один пиксель. Пиксели тоже половинами рисовать нельзя. Высота блока также фиксирована - одна строка.
Точно также как и у пикселя, у каждого блока есть позиция:
номер строки,
номер колонки или, другими словами, номер блока в строке.
Нужен способ последовательно запрашивать/генерировать блоки, указывая номер строки и номер блока в строке.
public class DrawerProcessor {
public void Draw(
// запрашивает блок по номеру строки и номеру блока в строке
Func<int, int, DrawerBlock> blockDraw,
// вызывается когда блок можно отрисовать
Action<string, bool> onBlockDraw) {
int row = 0;
int blockIndex = 0;
var done = false;
while (!done) {
var block = blockDraw(row, blockIndex);
switch (block.Command) {
case DrawerCommand.Continue:
blockIndex = blockIndex + block.Displacement;
break;
case DrawerCommand.NewLine:
row = row + 1;
blockIndex = 0;
break;
case DrawerCommand.End:
done = true;
break;
}
onBlockDraw(block.Value, done);
}
}
}
public class DrawerBlock {
public string Value { get; set; }
public DrawerCommand Command { get; set; }
// Смещение индекса блока,
// Value может содержать один или несколько блоков
// Если в Value два блока индекс следующего блока надо сместить на 2
public int Displacement { get; set; } = 1;
}
DrawerProcessor только следит за текущей позицией блока. DrawerProcessor не принимает никаких решений:
он не формирует содержимое блоков,
не решает начать новую строку или нет,
не определяет продолжать отрисовку или прервать.
Использование DrawerProcessor:
var drawer = new DrawerProcessor();
drawer.Draw(
(row, blockIndex) => {
// вся логика отрисовки в одном куске кода
if (row == 3)
return new DrawerBlock {
Command = DrawerCommand.End
};
if(blockIndex == 3)
return new DrawerBlock {
Value = Environment.NewLine,
Command = DrawerCommand.NewLine
};
return new DrawerBlock {
Value = $"[{row},{blockIndex}]",
Command = DrawerCommand.Continue
};
},
(blockStr, isEnd) => Console.Write(blockStr)
);
Вывод
[0,0][0,1][0,2]
[1,0][1,1][1,2]
[2,0][2,1][2,2]
// Листинг 1. Использование DrawerProcessor.
// Не лучший вариант для сложной логики рисования - вся отрисовка
// в одном куске кода (в делегате (row, blockIndex) => { .. }),
// который будет разрастаться.
Зачем может потребоваться выводить сразу двойные блоки, и следовательно, зачем нужно свойство DrawerBlock.Displacement? Предположим - длина блока 1 символ, нужно отобразить интервалы и подписи к концам:
8 11
|---------|
“11” - это два символа. Отрисовывать “11” двумя разными блоками сложно - нужно запомнить, что “пишем “11”, “1” уже написали - значит сейчас выводим еще “1” ”. А если надо написать трехзначное число? Проще сразу нарисовать “11” и пропустить отрисовку следующего блока: установить DrawerBlock.Displacement = 2.
Выражаясь наукообразно: если рисовать “11” двумя разными блоками, появляется необходимость хранить состояние (знать, что рисовал предыдущий блок и возможно пред-предыдущий). Увеличивается связь между блоками (предыдущий блок нарисовал “1” - значит сейчас надо нарисовать еще “1”), т.е. увеличивается "связность кода". Связность приводит к усложнению.
Немного удобства:
static class Block {
public static DrawerBlock Continue(string val, int displacement = 1)
=> new() {
Command = DrawerCommand.Continue,
Value = val,
Displacement = displacement
};
public static DrawerBlock End() =>
new() { Command = DrawerCommand.End };
public static DrawerBlock NewLine() =>
new() {
Command = DrawerCommand.NewLine,
Value = Environment.NewLine
};
}
Теперь такой код:
return new DrawerBlock {
Value = Environment.NewLine,
Command = DrawerCommand.NewLine
};
можно писать короче:
return Block.NewLine();
Рисуем несвязанными между собой блоками
В листинге 1 вся логика отрисовки находится в одном куске кода (в делегате (row, blockIndex) => { .. }). Пока этой логики не много, это наглядно и удобно. Но при разрастании, появлении новых требований/условий - код делегата (row, blockIndex) => { .. } будет расти и усложняться.
Пример: в листинге 1 рисовали кирпичики с цифрами:
[0,0][0,1][0,2]
[1,0][1,1][1,2]
[2,0][2,1][2,2]
теперь нужно писать цифры только на кирпичиках в шахматном порядке:
[ ][0,1][ ]
[1,0][ ][1,2]
[ ][2,1][ ]
Придется править и усложнять код делегата (row, blockIndex) => { .. }.
Выражаясь наукообразно: произойдет нарушение “принципа открытости/закрытости” - каждое новое требование влечет за собой необходимость изменения ранее написанного кода, т.е. код “не открыт для расширения” - не можем расширить логику отрисовки. И код “не закрыт для изменений” - придется изменять уже написанный код.
Т.е. такой подход, как и необходимость хранить состояние (см. предыдущий раздел), увеличивает связность кода. Связность приводит к усложнению.
Поправить ситуацию можно следующим образом. Пусть для каждого блока (“кирпичик с цифрами”, “кирпичик без цифр”, “блок конца строки”, “завершающий блок”) будет отдельная функция (делегат).
// блок "конец"
DrawerBlock end(int row, int blockIndex) =>
row == 3 ? Block.End() : null;
// блок "новая строка"
DrawerBlock newLine(int row, int blockIndex) =>
blockIndex == 3 ? Block.NewLine() : null;
// блок "кирпичик с цифрами"
DrawerBlock brick(int row, int blockIndex) =>
Block.Continue($"[{row},{blockIndex}]");
При рисовании нужно просто перебирать функции блоков:
public class BlockDrawer {
readonly DrawerProcessor _DrawerProcessor;
public BlockDrawer(DrawerProcessor drawerProcessor) {
_DrawerProcessor = drawerProcessor
?? throw new ArgumentNullException(nameof(drawerProcessor));
}
public void Draw(
IReadOnlyCollection<Func<int, int, DrawerBlock>> blockDrawers,
Action<string, bool> onBlockDraw) {
_DrawerProcessor.Draw(
(row, blockIndex) => {
foreach (var bd in blockDrawers) {
var block = bd(row, blockIndex);
if (block != null)
return block;
}
return
new DrawerBlock { Command = DrawerCommand.End };
},
onBlockDraw
);
}
}
Пример использования:
// обратите внимание: порядок блоков важен
var blockDrawers = new Func<int, int, DrawerBlock>[] {
end,
newLine,
brick
};
var drawer = new DrawerProcessor();
var blockDrawer = new BlockDrawer(drawer);
blockDrawer.Draw(
blockDrawers,
(blockStr, isEnd) => Console.Write(blockStr));
Сейчас программа рисует тоже самое, что программа в листинге 1 - только кирпичики с цифрами.
Добавим кирпичики без цифр:
static void Main(string[] args) {
DrawerBlock end(int row, int blockIndex) => ...;
DrawerBlock newLine(int row, int blockIndex) => ...;
DrawerBlock brick(int row, int blockIndex) => ...;
// кирпичик без цифр
DrawerBlock brickEmpty(int row, int blockIndex) =>
((row + blockIndex) % 2 == 0) ? Block.Continue($"[ ]") : null;
var blockDrawers = new Func<int, int, DrawerBlock>[] {
end,
newLine,
brickEmpty, // важно вставить перед brick
brick
};
var drawer = new DrawerProcessor();
var blockDrawer = new BlockDrawer(drawer);
blockDrawer.Draw(
blockDrawers,
(blockStr, isEnd) => Console.Write(blockStr));
}
Результат
[ ][0,1][ ]
[1,0][ ][1,2]
[ ][2,1][ ]
// Листинг 2. Использование DrawerProcessor.
// За отрисовку каждого блока отвечает отдельная функция.
// Функции захардкожены в main - не лучший варинт,
// если блоков будет много или они будут более сложные.
Выражаясь наукообразно: использован паттерн “цепочка обязанностей”. Функции рисования блоков вызываются по цепочке. Функции не связаны между собой. Можно добавлять новые блоки, не изменяя написанный код. Однако отметим, что слабая связь между блоками все же есть - важно вызывать их в нужной последовательности.
В листинге 2 функции захардкожены в методе Main. Нет возможности добавить новую функцию рисования без изменения метода Main. Исправим. Пусть для каждой функции рисования блока будет отдельный класс.
public interface IContextDrawerBlock<TDrawerContext> {
int Priority { get; }
DrawerBlock Draw(int row, int blockIndex, TDrawerContext context);
}
В интерфейс блоков рисования добавлен context, чтобы была возможность задавать разные параметры отрисовки. Например, ниже класс блока конца отрисовки узнает о максимальном кол-ве строк через context.RowCount:
class EndDrawer : IContextDrawerBlock<SampleDrawContext> {
public int Priority => 10;
public DrawerBlock Draw(int row, int blockIndex,
SampleDrawContext context)
=> row == context.RowCount ? Block.End() : null;
}
Как и с функциями, классы блоков надо последовательно перебрать:
public class ContextBlockDrawer<TDrawerContext> {
readonly IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> _BlockDrawers;
readonly BlockDrawer _Drawer;
public ContextBlockDrawer(
BlockDrawer drawer,
IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> blockDrawers) {
_Drawer = drawer ?? throw ...
_BlockDrawers = blockDrawers?.Any() == true
? blockDrawers.OrderBy(bd => bd.Priority).ToArray()
: throw ...
}
public void Draw(TDrawerContext drawerContext,
Action<string, bool> onBlockDraw) {
var drawers = _BlockDrawers.Select(bd => {
DrawerBlock draw(int row, int blockIndex) =>
bd.Draw(row, blockIndex, drawerContext);
return (Func<int, int, DrawerBlock>)draw;
})
.ToArray();
_Drawer.Draw(drawers, onBlockDraw);
}
}
Теперь пример с кирпичиками будет выглядеть так:
// Создание ContextBlockDrawer
var drawer = new DrawerProcessor();
var blockDrawer = new BlockDrawer(drawer);
var blockDrawers = new IContextDrawerBlock<SampleDrawContext>[] {
new EndDrawer(),
new EndLineDrawer(),
new BrickEmptyDrawer(),
new BrickDrawer(),
};
var ctxBlockDrawer = new ContextBlockDrawer<SampleDrawContext>(
blockDrawer,
blockDrawers);
// использование ContextBlockDrawer
ctxBlockDrawer.Draw(
new SampleDrawContext {
RowCount = 3,
BlockCount = 3
},
(blockStr, isEnd) => Console.Write(blockStr));
// Листинг 3. Использование ContextBlockDrawer.
// Каждая функция рисования блока в отдельном классе.
// Введен контекст рисования.
// Классы блоков выглядят не связанными между собой,
// однако это не совсем так - мешает необходимость задавать Priority.
Обратите внимание: каждый класс блока имеет свойство Priority, поэтому при создании нового блока придется посмотреть какие Priority у других блоков. По крайней мере приоритет нового блока не должен быть выше “блока конца”. Т.е. на самом деле между блоками есть связь (хотя и не очень сильная).
Наращиваем функционал переиспользуя старый код, стараемся при переиспользовании минимизировать связи
Посмотрите на код создания ContextBlockDrawer в листинге 3. ContextBlockDrawer использует (переиспользует) в своей работе BlockDrawer и получает его экземпляр через конструктор. BlockDrawer, в свою очередь, использует (переиспользует) DrawerProcessor, и также получает его через конструктор.
Выражаясь наукообразно:
ContextBlockDrawer ->зависит от (читай использует)-> BlockDrawer -> зависит от -> DrawerProcessor.
Получение зависимости через конструктор называется “агрегацией”.
В листинге 3 при создании ContextBlockDrawer фактически реализовано “внедрение зависимостей”. При этом самый явный (читай лучший) вариант: “внедрение зависимостей через конструктор”:
- открывая код класса, сразу видно его зависимости (все зависимости в одном месте - в конструкторе)
- экземпляр класса нельзя получить, не установив его зависимости.
Для сравнения: еще зависимость можно внедрять, например, через поле или получать через “сервис локатор”:
// внедрение зависимости через поле
// (хуже внедрения через конструктор)
var ctxBlockDrawer = new ContextBlockDrawer();
ctxBlockDrawer.BlockDrawer = blockDrawer;
// получение зависимости через сервис локатор
// (хуже внедрения через конструктор)
public class ContextBlockDrawer<TDrawerContext> {
...
public void Draw(TDrawerContext drawerContext,
Action<string, bool> onBlockDraw) {
...
var blockDrawer = ServiceLocator.Get<BlockDrawer>();
...
}
}
// Варианты получения зависимостей которые лучше избегать
Кроме получения зависимости BlockDrawer через конструктор (агрегация), можно прямо в ContextBlockDrawer создать экземпляр BlockDrawer (применить композицию):
public class ContextBlockDrawer<TDrawerContext> {
readonly IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> _BlockDrawers;
readonly BlockDrawer _Drawer;
public ContextBlockDrawer(
IReadOnlyCollection<IContextDrawerBlock<TDrawerContext>> blockDrawers) {
// композиция
var drawer = new DrawerProcessor();
_Drawer = new BlockDrawer(drawer);
...
}
тогда связь между ContextBlockDrawer и BlockDrawer будет более сильной: ContextBlockDrawer не просто будет использовать BlockDrawer, но и должен будет знать как создать BlockDrawer. И знать о зависимостях BlockDrawer(о DrawerProcessor). Т.е. связность кода возрастет, а вместе с ней возрастет и сложность системы.
На этом этапе готов ASCII генератор, которого достаточно для рисования подобных схем - демонстрация работы утилиты интервалов.
Декартова система координат
Рисовать графики зависимости y от x с помощью ContextBlockDrawer можно, но неудобно:
public interface IContextDrawerBlock<TDrawerContext> {
int Priority { get; }
DrawerBlock Draw(int row, int blockIndex, TDrawerContext context);
}
Draw принимает row и blockIndex. Рисование идет справа налево и сверху вниз. Для графиков y от х было бы удобней:
public interface IСartesianDrawerBlock<TDrawerContext> {
int Priority { get; }
DrawerBlock Draw(float x, float y, TDrawerContext context);
}
Например, этот блок рисует прямую:
class LineDrawer : IСartesianDrawerBlock<СartesianDrawerContext> {
public int Priority => 40;
public DrawerBlock Draw(float x, float y,
СartesianDrawerContext context) {
var y1 = x; // уравнение прямой y=x
// если вычисленное y1 равно текущей позиции y
// (c учетом округления)
if (Math.Abs(y1 -y) <= context.Rounding)
return Block.Continue("#");
return null;
}
}
Нужно сделать возможным использовать новый IСartesianDrawerBlock с уже реализованным ContextBlockDrawer. Нужен адаптер, который стыкует “Draw(int row, int blockIndex, TDrawerContext context)” и “DrawerBlock Draw(float x, float y, TDrawerContext context)”:
public class СartesianDrawerAdapter<TDrawerContext> :
IContextDrawerBlock<TDrawerContext>
where TDrawerContext : IСartesianDrawerAdapterContext {
readonly IСartesianDrawerBlock<TDrawerContext> _cartesianDrawer;
public СartesianDrawerAdapter(
IСartesianDrawerBlock<TDrawerContext> cartesianDrawer) {
_cartesianDrawer = cartesianDrawer ?? throw ...
}
public int Priority => _cartesianDrawer.Priority;
public DrawerBlock Draw(int row, int blockIndex, TDrawerContext context) {
float x = blockIndex / context.Scale + context.XMin;
float y = context.YMax - row / context.Scale;
return _cartesianDrawer.Draw(x, y, context);
}
}
public interface IСartesianDrawerAdapterContext {
public float Scale { get; }
public float XMin { get; }
public float YMax { get; }
}
Пример использования СartesianDrawerAdapter - график прямой:
// создание ctxBlockDrawer для декартовой системы координат
var drawer = new DrawerProcessor();
var blockDrawer = new BlockDrawer(drawer);
var blockDrawers = new IСartesianDrawerBlock<СartesianDrawerContext>[] {
new EndDrawer(),
new EndLineDrawer(),
new LineDrawer(),
new EmptyDrawer()
}
.Select(dd =>
// применение адаптера
new СartesianDrawerAdapter<СartesianDrawerContext>(dd))
.ToArray();
var ctxBlockDrawer = new ContextBlockDrawer<СartesianDrawerContext>(
blockDrawer,
blockDrawers);
// использование ctxBlockDrawer
ctxBlockDrawer.Draw(new СartesianDrawerContext {
XMin = -2,
XMax = 30,
YMin = -2,
YMax = 8,
Scale = 5,
Rounding = 0.1F
},
(blockStr, isEnd) => Console.Write(blockStr));
Выражаясь наукообразно: при переходе от IContextDrawerBlock к IСartesianDrawerBlock использован паттерн “адаптер” - СartesianDrawerAdapter.
Использование можно сделать красивее:
// создание
...
var ctxBlockDrawer = ...
var asciiDrawer =
new AsciiDrawer<СartesianDrawerContext>(ctxBlockDrawer);
// использование
asciiDrawer
// вот тут стало красивее
.OnBlockDraw((blockStr, isEnd) => Console.Write(blockStr))
.Draw(new СartesianDrawerContext {
XMin = -2,
XMax = 30,
...
});
// Листинг 4. Создание и использование AsciiDrawer.
Код AsciiDrawer:
public class AsciiDrawer<TDrawerContext> {
readonly ContextBlockDrawer<TDrawerContext> _ContextBlockDrawer;
readonly Action<string, bool> _onBlockDraw;
public AsciiDrawer(
ContextBlockDrawer<TDrawerContext> contextBlockDrawer,
Action<string, bool> onBlockDraw = null) {
_ContextBlockDrawer = contextBlockDrawer ?? throw ...
_onBlockDraw = onBlockDraw;
}
public AsciiDrawer<TDrawerContext> OnBlockDraw(
Action<string, bool> onBlockDraw) {
// создаем новый экземпляр
// возвращать this (return this) небезопасно при многопоточности
return new AsciiDrawer<TDrawerContext>(
_ContextBlockDrawer,
onBlockDraw);
}
public void Draw(TDrawerContext context) {
if (_onBlockDraw == null)
throw new InvalidOperationException("Use .OnBlockDraw to set up draw output");
_ContextBlockDrawer.Draw(context, _onBlockDraw);
}
}
Выражаясь наукообразно: AsciiDrawer сделан “неизменным” - нет методов, которые бы изменяли его состояние. OnBlockDraw возвращает новый экземпляр (а не this). “Неизменность” делает класс безопасным для многопоточности.
Все SingleInstance
В листинге 4 все, что под комментарием “Создание”, можно вынести в регистрацию IoC контейнера. В приложении просто получать и использовать готовый AsciiDrawer.
Объекты ASCII рисовальщика не хранят состояний, еще они “неизменяемы” - значит можно без опасений использовать одни и те же инстансы в разных местах. В том числе наши объекты можно зарегистрировать в IoC контейнере как SingleInstance.
В итоге в Blazor WebAssembly демонстрации по клику на кнопке Run следующий код:
var res = new StringBuilder();
AsciiDrw
.OnBlockDraw((blockStr, isEnd) => {
res.Append(blockStr);
if (isEnd)
// обновление UI
Res = res.ToString();
})
.Draw(new FuncsDrawerContext {
// поля формы
Rounding = Rounding,
Scale = Scale,
XMin = Xmin,
XMax = Xmax,
YMin = Ymin,
YMax = Ymax,
// функции для y от x
Functions = funcs
});
В демонстрации используются следующие блоки:
new EndDrawer(),
new EndLineDrawer(),
new FuncsDrawer(), // рисует функции указанные в контексте
new XAxisDrawer(), // рисует ось X
new YAxisDrawer(), // рисует ось Y
new EmptyDrawer()
Можно еще придумать:
блок, который закрашивает площадь под графиком,
блок который выводит шкалу на осях,
блок, который подписывает точки пересечения графиков.
Заключение
Как видите, даже школьную задачку можно серьезно запутать - главное знать принципы и паттерны.
justboris
картинка с троллейбусом из буханки
Выглядит прикольно, но зачем? Есть же уже готовые библиотеки для визуализации данных
XopHeT
Самое смешное, что в процессе чтения статьи у меня у самого возникло желание написать что-нибудь подобное на плюсах.
Ваш комментарий меня не остановит!)