feat: Add packet diagram

This commit is contained in:
Sidharth Vinod 2023-09-14 22:35:13 +05:30
parent 271b779995
commit c41594d220
No known key found for this signature in database
GPG Key ID: FB5CCD378D3907CD
25 changed files with 534 additions and 9 deletions

View File

@ -23,6 +23,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
'gitGraph',
'c4',
'sankey',
'packet',
] as const;
/**

View File

@ -9,9 +9,10 @@ import { generateLangium } from '../.build/generateLangium.js';
const parserCtx = await context(
getBuildConfig({ ...defaultOptions, minify: false, core: false, entryName: 'parser' })
);
const mermaidCtx = await context(
getBuildConfig({ ...defaultOptions, minify: false, core: false, entryName: 'mermaid' })
);
const mermaidCtx = await context({
...getBuildConfig({ ...defaultOptions, minify: false, core: false, entryName: 'mermaid' }),
sourcemap: 'linked',
});
const mermaidIIFECtx = await context(
getBuildConfig({
...defaultOptions,

47
demos/packet.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Mermaid Quick Test Page</title>
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=" />
<style>
div.mermaid {
font-family: 'Courier New', Courier, monospace !important;
}
</style>
</head>
<body>
<h1>Packet diagram demo</h1>
<pre class="mermaid">
packet-beta
0-15: "Source Port"
16-31: "Destination Port"
32-63: "Sequence Number"
64-95: "Acknowledgment Number"
96-99: "Data Offset"
100-105: "Reserved"
106: "URG"
107: "ACK"
108: "PSH"
109: "RST"
110: "SYN"
111: "FIN"
112-127: "Window"
128-143: "Checksum"
144-159: "Urgent Pointer"
160-191: "(Options and Padding)"
192-223: "data"
</pre>
<script type="module">
import mermaid from '/mermaid.esm.mjs';
mermaid.initialize({
theme: 'forest',
logLevel: 3,
securityLevel: 'loose',
});
</script>
</body>
</html>

View File

@ -14,7 +14,7 @@
#### Defined in
[defaultConfig.ts:268](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L268)
[defaultConfig.ts:275](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L275)
---

View File

@ -140,10 +140,43 @@ export interface MermaidConfig {
gitGraph?: GitGraphDiagramConfig;
c4?: C4DiagramConfig;
sankey?: SankeyDiagramConfig;
packet?: PacketDiagramConfig;
dompurifyConfig?: DOMPurifyConfiguration;
wrap?: boolean;
fontSize?: number;
}
/**
* The object containing configurations specific for packet diagrams.
*
* This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "PacketDiagramConfig".
*/
export interface PacketDiagramConfig extends BaseDiagramConfig {
/**
* The height of each row in the packet diagram.
*/
rowHeight?: number;
/**
* The width of each bit in the packet diagram.
*/
bitWidth?: number;
/**
* The number of bits to display per row.
*/
bitsPerRow?: number;
/**
* Toggle to display or hide bit numbers.
*/
showBits?: boolean;
/**
* The horizontal padding between the blocks in a row.
*/
paddingX?: number;
/**
* The vertical padding between the rows.
*/
paddingY?: number;
}
/**
* This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "BaseDiagramConfig".

View File

@ -253,6 +253,13 @@ const config: RequiredDeep<MermaidConfig> = {
// TODO: can we make this default to `true` instead?
useMaxWidth: false,
},
packet: {
...defaultConfigJson.packet,
useWidth: undefined,
// this is false, unlike every other diagram (other than gitGraph)
// TODO: can we make this default to `true` instead?
useMaxWidth: false,
},
};
const keyify = (obj: any, prefix = ''): string[] =>

View File

@ -19,6 +19,7 @@ import flowchartElk from '../diagrams/flowchart/elk/detector.js';
import timeline from '../diagrams/timeline/detector.js';
import mindmap from '../diagrams/mindmap/detector.js';
import sankey from '../diagrams/sankey/sankeyDetector.js';
import { packet } from '../diagrams/packet/detector.js';
import { registerLazyLoadedDiagrams } from './detectType.js';
import { registerDiagram } from './diagramAPI.js';
@ -84,6 +85,7 @@ export const addDiagrams = () => {
state,
journey,
quadrantChart,
sankey
sankey,
packet
);
};

View File

@ -0,0 +1,101 @@
import type { Block, PacketDB, Word } from './types.js';
import { log } from '../../logger.js';
import type { PacketDiagramConfig } from '../../config.type.js';
import DEFAULT_CONFIG from '../../defaultConfig.js';
import { getConfig as commonGetConfig } from '../../config.js';
interface PacketData {
packet: Word[];
}
const defaultPacketData: PacketData = {
packet: [],
};
let data: PacketData = structuredClone(defaultPacketData);
export const DEFAULT_PACKET_CONFIG: Required<PacketDiagramConfig> = DEFAULT_CONFIG.packet;
export const getConfig = (): Required<PacketDiagramConfig> => {
return structuredClone({
...DEFAULT_PACKET_CONFIG,
...commonGetConfig().packet,
});
};
export const getPacket = (): Word[] => data.packet;
export const getNextFittingBlock = (
block: Block,
row: number,
bitsPerRow: number
): [Block, Block | undefined] => {
block.end = block.end ?? block.start;
if (block.start > block.end) {
throw new Error(`Block start ${block.start} is greater than block end ${block.end}.`);
}
if (block.end + 1 <= row * bitsPerRow) {
return [block, undefined];
}
return [
{
start: block.start,
end: row * bitsPerRow - 1,
label: block.label,
},
{
start: row * bitsPerRow,
end: block.end,
label: block.label,
},
];
};
export const populate = ({ blocks }: { blocks: Block[] }) => {
let lastByte = -1;
let word: Block[] = [];
data.packet = [];
let row = 1;
const { bitsPerRow } = getConfig();
for (let { start, end, label } of blocks) {
if (end < start) {
throw new Error(`Packet block ${start} - ${end} is invalid. End must be greater than start.`);
}
if (start != lastByte + 1) {
throw new Error(
`Packet block ${start} - ${end} is not contiguous. It should start from ${lastByte + 1}.`
);
}
lastByte = end ?? start;
log.debug(`Packet block ${start} - ${lastByte} with label ${label}`);
while (word.length <= bitsPerRow + 1 && data.packet.length < 10_000) {
const [block, nextBlock] = getNextFittingBlock({ start, end, label }, row, bitsPerRow);
word.push(block);
if (block.end + 1 === row * bitsPerRow) {
data.packet.push(word);
word = [];
row++;
}
if (!nextBlock) {
break;
}
({ start, end, label } = nextBlock);
}
}
if (word.length > 0) {
data.packet.push(word);
}
log.debug(data);
};
export const clear = () => {
data = structuredClone(defaultPacketData);
};
export const db: PacketDB = {
getPacket,
getConfig,
};

View File

@ -0,0 +1,22 @@
import type {
DiagramDetector,
DiagramLoader,
ExternalDiagramDefinition,
} from '../../diagram-api/types.js';
const id = 'packet';
const detector: DiagramDetector = (txt) => {
return /^\s*packet-beta/.test(txt);
};
const loader: DiagramLoader = async () => {
const { diagram } = await import('./diagram.js');
return { id, diagram };
};
export const packet: ExternalDiagramDefinition = {
id,
detector,
loader,
};

View File

@ -0,0 +1,12 @@
import type { DiagramDefinition } from '../../diagram-api/types.js';
import { parser } from './parser.js';
import { db } from './db.js';
import { renderer } from './renderer.js';
import { styles } from './styles.js';
export const diagram: DiagramDefinition = {
parser,
db,
renderer,
styles,
};

View File

@ -0,0 +1,31 @@
import { parser } from './parser.js';
describe('info', () => {
it('should handle an info definition', () => {
const str = `info`;
expect(() => {
parser.parse(str);
}).not.toThrow();
});
it('should handle an info definition with showInfo', () => {
const str = `info showInfo`;
expect(() => {
parser.parse(str);
}).not.toThrow();
});
it('should throw because of unsupported info grammar', () => {
const str = `info unsupported`;
expect(() => {
parser.parse(str);
}).toThrow('Parsing failed: unexpected character: ->u<- at offset: 5, skipped 11 characters.');
});
it('should throw because of unsupported info grammar', () => {
const str = `info unsupported`;
expect(() => {
parser.parse(str);
}).toThrow('Parsing failed: unexpected character: ->u<- at offset: 5, skipped 11 characters.');
});
});

View File

@ -0,0 +1,14 @@
import type { Packet } from 'mermaid-parser';
import type { ParserDefinition } from '../../diagram-api/types.js';
import { parse } from 'mermaid-parser';
import { log } from '../../logger.js';
import { populate } from './db.js';
export const parser: ParserDefinition = {
parse: (input: string): void => {
const ast: Packet = parse('packet', input);
log.debug(ast);
populate(ast);
},
};

View File

@ -0,0 +1,81 @@
import { configureSvgSize } from '../../setupGraphViewbox.js';
import type { DrawDefinition, Group, SVG } from '../../diagram-api/types.js';
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
import type { PacketDB, Word } from './types.js';
import type { PacketDiagramConfig } from '../../config.type.js';
import type { Diagram } from '../../Diagram.js';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => {
const db = diagram.db as PacketDB;
const config = db.getConfig?.() as Required<PacketDiagramConfig>;
const { rowHeight, paddingY, bitWidth, bitsPerRow } = config;
const words = db.getPacket();
const svgHeight = (rowHeight + paddingY) * words.length + paddingY;
const svgWidth = bitWidth * bitsPerRow + 2;
const svg: SVG = selectSvgElement(id);
configureSvgSize(svg, svgHeight, svgWidth, true);
svg.attr('height', svgHeight + 'px');
for (const [row, packet] of words.entries()) {
drawWord(svg, packet, row, config);
}
};
const drawWord = (
svg: SVG,
word: Word,
row: number,
{ rowHeight, paddingX, paddingY, bitWidth, bitsPerRow }: Required<PacketDiagramConfig>
) => {
const group: Group = svg.append('g');
const wordY = row * (rowHeight + paddingY) + paddingY;
for (const block of word) {
const blockX = (block.start % bitsPerRow) * bitWidth + 1;
const width = (block.end - block.start + 1) * bitWidth - paddingX;
// Block rectangle
group
.append('rect')
.attr('x', blockX)
.attr('y', wordY)
.attr('width', width)
.attr('height', rowHeight)
.attr('class', 'block');
// Block label
group
.append('text')
.attr('x', blockX + width / 2)
.attr('y', wordY + rowHeight / 2)
.attr('class', 'label')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.text(block.label);
// Start byte count
const isSingleBlock = block.end === block.start;
const byteNumberY = wordY - 2;
group
.append('text')
.attr('x', blockX + (isSingleBlock ? width / 2 : 0))
.attr('y', byteNumberY)
.attr('class', 'byte start')
.attr('dominant-baseline', 'auto')
.attr('text-anchor', isSingleBlock ? 'middle' : 'start')
.text(block.start);
// Draw end byte count if it is not the same as start byte count
if (!isSingleBlock) {
group
.append('text')
.attr('x', blockX + width)
.attr('y', byteNumberY)
.attr('class', 'byte end')
.attr('dominant-baseline', 'auto')
.attr('text-anchor', 'end')
.text(block.end);
}
}
};
export const renderer = { draw };

View File

@ -0,0 +1,27 @@
import { log } from '../../logger.js';
export const styles = (options: any = {}) => {
log.debug({ options });
return `
.byte {
font-size: ${options.packet?.byteFontSize ?? '10px'};
}
.byte.start {
fill: ${options.packet?.startByteColor ?? 'black'};
}
.byte.end {
fill: ${options.packet?.endByteColor ?? 'black'};
}
.label {
fill: ${options.packet?.labelColor ?? 'black'};
font-size: ${options.packet?.labelFontSize ?? '12px'};
}
.block {
stroke: ${options.packet?.blockStrokeColor ?? 'black'};
stroke-width: ${options.packet?.blockStrokeWidth ?? '1'};
fill: ${options.packet?.blockFillColor ?? '#efefef'};
}
`;
};
export default styles;

View File

@ -0,0 +1,10 @@
import type { Packet } from 'mermaid-parser';
import type { DiagramDB } from '../../diagram-api/types.js';
export type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
export type Block = Pick<ArrayElement<Packet['blocks']>, 'start' | 'end' | 'label'>;
export type Word = Block[];
export interface PacketDB extends DiagramDB {
getPacket: () => Word[];
}

View File

@ -48,6 +48,7 @@ required:
- gitGraph
- c4
- sankey
- packet
properties:
theme:
description: |
@ -201,6 +202,8 @@ properties:
$ref: '#/$defs/C4DiagramConfig'
sankey:
$ref: '#/$defs/SankeyDiagramConfig'
packet:
$ref: '#/$defs/PacketDiagramConfig'
dompurifyConfig:
title: DOM Purify Configuration
description: Configuration options to pass to the `dompurify` library.
@ -1853,6 +1856,32 @@ $defs: # JSON Schema definition (maybe we should move these to a seperate file)
type: string
default: ''
PacketDiagramConfig:
title: Packet Diagram Config
allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]
description: The object containing configurations specific for packet diagrams.
type: object
unevaluatedProperties: false
properties:
rowHeight:
description: The height of each row in the packet diagram.
default: 32
bitWidth:
description: The width of each bit in the packet diagram.
default: 32
bitsPerRow:
description: The number of bits to display per row.
default: 32
showBits:
description: Toggle to display or hide bit numbers.
default: true
paddingX:
description: The horizontal padding between the blocks in a row.
default: 5
paddingY:
description: The vertical padding between the rows.
default: 15
FontCalculator:
title: Font Calculator
description: |

View File

@ -28,6 +28,7 @@ import state from './diagrams/state/styles.js';
import journey from './diagrams/user-journey/styles.js';
import timeline from './diagrams/timeline/styles.js';
import mindmap from './diagrams/mindmap/styles.js';
import packet from './diagrams/packet/styles.js';
import themes from './themes/index.js';
async function checkValidStylisCSSStyleSheet(stylisString: string) {
@ -96,6 +97,7 @@ describe('styles', () => {
sequence,
state,
timeline,
packet,
})) {
test(`should return a valid style for diagram ${diagramId} and theme ${themeId}`, async () => {
const { default: getStyles, addStylesForDiagram } = await import('./styles.js');

View File

@ -5,6 +5,11 @@
"id": "info",
"grammar": "src/language/info/info.langium",
"fileExtensions": [".mmd", ".mermaid"]
},
{
"id": "packet",
"grammar": "src/language/packet/packet.langium",
"fileExtensions": [".mmd", ".mermaid"]
}
],
"mode": "production",

View File

@ -1,3 +1,3 @@
export type { Info } from './language/index.js';
export type { Info, Packet } from './language/index.js';
export type { DiagramAST } from './parse.js';
export { parse, MermaidParseError } from './parse.js';

View File

@ -4,3 +4,4 @@ export * from './generated/module.js';
export * from './common/index.js';
export * from './info/index.js';
export * from './packet/index.js';

View File

@ -0,0 +1 @@
export * from './module.js';

View File

@ -0,0 +1,72 @@
import type {
DefaultSharedModuleContext,
LangiumServices,
LangiumSharedServices,
Module,
PartialLangiumServices,
} from 'langium';
import { EmptyFileSystem, createDefaultModule, createDefaultSharedModule, inject } from 'langium';
import { CommonLexer } from '../common/lexer.js';
import { MermaidGeneratedSharedModule, PacketGeneratedModule } from '../generated/module.js';
import { PacketTokenBuilder } from './tokenBuilder.js';
import { CommonValueConverter } from '../common/valueConverter.js';
/**
* Declaration of `Packet` services.
*/
type PacketAddedServices = {
parser: {
Lexer: CommonLexer;
TokenBuilder: PacketTokenBuilder;
ValueConverter: CommonValueConverter;
};
};
/**
* Union of Langium default services and `Packet` services.
*/
export type PacketServices = LangiumServices & PacketAddedServices;
/**
* Dependency injection module that overrides Langium default services and
* contributes the declared `Packet` services.
*/
export const PacketModule: Module<PacketServices, PartialLangiumServices & PacketAddedServices> = {
parser: {
Lexer: (services: PacketServices) => new CommonLexer(services),
TokenBuilder: () => new PacketTokenBuilder(),
ValueConverter: () => new CommonValueConverter(),
},
};
/**
* Create the full set of services required by Langium.
*
* First inject the shared services by merging two modules:
* - Langium default shared services
* - Services generated by langium-cli
*
* Then inject the language-specific services by merging three modules:
* - Langium default language-specific services
* - Services generated by langium-cli
* - Services specified in this file
* @param context - Optional module context with the LSP connection
* @returns An object wrapping the shared services and the language-specific services
*/
export function createPacketServices(context: DefaultSharedModuleContext = EmptyFileSystem): {
shared: LangiumSharedServices;
Packet: PacketServices;
} {
const shared: LangiumSharedServices = inject(
createDefaultSharedModule(context),
MermaidGeneratedSharedModule
);
const Packet: PacketServices = inject(
createDefaultModule({ shared }),
PacketGeneratedModule,
PacketModule
);
shared.ServiceRegistry.register(Packet);
return { shared, Packet };
}

View File

@ -0,0 +1,14 @@
grammar Packet
import "../common/common";
entry Packet:
"packet-beta" NEWLINE*
TitleAndAccessibilities?
(blocks+=Block)*;
Block:
start=INT ('-' end=INT)? ':' label=STRING;
hidden terminal WS: /\s+/;
terminal INT returns number: /[0-9]+/;
terminal STRING: /"[^"]*"|'[^']*'/;

View File

@ -0,0 +1,7 @@
import { MermaidTokenBuilder } from '../common/index.js';
export class PacketTokenBuilder extends MermaidTokenBuilder {
public constructor() {
super(['packet-beta']);
}
}

View File

@ -1,8 +1,8 @@
import type { LangiumParser, ParseResult } from 'langium';
import type { Info } from './index.js';
import { createInfoServices } from './language/index.js';
import type { Info, Packet } from './index.js';
import { createInfoServices, createPacketServices } from './language/index.js';
export type DiagramAST = Info;
export type DiagramAST = Info | Packet;
const parsers: Record<string, LangiumParser> = {};
@ -13,8 +13,13 @@ const initializers = {
const parser = createInfoServices().Info.parser.LangiumParser;
parsers['info'] = parser;
},
packet: () => {
const parser = createPacketServices().Packet.parser.LangiumParser;
parsers['packet'] = parser;
},
} as const;
export function parse(diagramType: 'info', text: string): Info;
export function parse(diagramType: 'packet', text: string): Packet;
export function parse<T extends DiagramAST>(
diagramType: keyof typeof initializers,
text: string