## Software, Companies and software development Nordeck is a company based in Hamburg, Germany. NeoBoard is a web application developed by Nordeck. The Element client is developed by the company Element, which is based in London, United Kingdom. NeoBoard can be embedded as a widget into the Element client. The Element client runs on Web, Desktop, Android and iOS. Only on Web and Desktop does the Element client support widgets. ## The NeoBoard application The application NeoBoard is used to create whiteboards. A NeoBoard whiteboard has a screen resolution of 1920 by 1080 pixels. This means, a NeoBoard whiteboard is 1920 pixels wide and 1080 pixels high. This means, a NeoBoard whiteboard has an aspect ratio of 16 to 9. ## The basics of NeoBoard whiteboards A whiteboard contains one or more slides. There is no limit as to how many slides a whiteboard can have. A whiteboard contains any amount of elements. There is no limit as to how many elements a slide can have. A whiteboard can be stored in a JSON file. JSON files must not contain JavaScript comments. The file ending of a whiteboard is nwb. An example file name is `presentation.nwb`. ## NeoBoard file format This is an example of a NeoBoard whiteboard. It has one slide with no content. ```json { "version":"net.nordeck.whiteboard@v1", "whiteboard": { "slides": [ { "elements": [] } ] } } ``` The next whiteboard has one slide. On the first slide is a red rectangle. ```json { "version":"net.nordeck.whiteboard@v1", "whiteboard": { "slides": [ { "elements": [ { "position": { "x": 0, "y": 0, }, "type": "shape", "kind": "rectangle", "fillColor": "#ff0000", "textColor": "#000000", "height": 64, "width": 64, "text": "", } ] } ] } } ``` ## Elements Elements can be placed on a slide. Elements must have a position, which must have a x coordinate and a y coordinate in pixels. The coordinate 0 | 0 is in the top left corner. The coordinate 1920 | 1080 is in the bottom right corner. All positions must be greater than 0 | 0 and lower than 1920 | 1080 to be visible on the screen. Also account for the width and height of shapes. When drawing a slide, scale all elements to fit the slide's resolution of 1920 by 1080 pixels by rescaling elements and their positions. The higher the x coordinate, the more an element is placed to the right. The higher the y coordinate, the more an element is places to the left. Note, that diagrams are drawn with the y-axis pointing upwards. This means that the y coordinate of a whiteboard element needs to decrease as the y coordinate of a diagram increases. Elements must have a type, which must be "shape" or "line". ### Shapes If an element's type is "shape", its kind must be "rectangle", "circle", "ellipse" or "triangle". There is no kind called "square". To display a square, ensure that the width and height of a rectangle is the same. Shapes can contain text. The attribute `textColor` sets the RGB text color like "#ff0000" for red. The attribute `fillColor` sets the color of the shape which is also the background color of the contained text. The `fillColor` can be and RGB color or "transpartent". ## Paths If an element's type is "shape", its kind must be "line" or "polyline". A line must contain two points. ```json {"version":"net.nordeck.whiteboard@v1","whiteboard":{"slides":[{"elements":[{"position":{"x":800,"y":200},"type":"path","kind":"line","points":[{"x":0,"y":0},{"x":160,"y":160}],"strokeColor":"#9e9e9e"}]}]}} ``` A polyline must contain at least two points. When drawing more than two points, the kind must be "polyline". ```json {"version":"net.nordeck.whiteboard@v1","whiteboard":{"slides":[{"elements":[{"type":"path","kind":"polyline","points":[{"x":0,"y":0},{"x":0,"y":0},{"x":1.5,"y":0},{"x":13.8,"y":9.5},{"x":24.3,"y":11.2},{"x":46.1,"y":28.3},{"x":67.4,"y":42.8},{"x":78.3,"y":50.4},{"x":89.9,"y":57.9},{"x":95.9,"y":63.3},{"x":106.4,"y":71.6},{"x":117.8,"y":78.5},{"x":125.2,"y":84.3},{"x":130.8,"y":87.8},{"x":132.7,"y":89.7},{"x":134.6,"y":91.6}],"strokeColor":"#9e9e9e"}]}]}} ``` ### Templates This next whiteboard has one slide. The slide contains an empty chart with two axis. Both axes have no values next to them which makes them suitable for charts where the values are irrelevant or cannot accurately be determined. The heading has the text "How are you feeling?". The x-axis is labeled "Something specific to the team". The y-axis is labeled "Satisfaction with the results". The origin of the chart has the whiteboard position 200 | 800. The chart's size is 1520 by 680 pixels. When drawing points into the chart, rescale the data to fit into the chart's size. Rescale x values to fit within 1520 pixels. Rescale the y-axis to fit within 680 pixels. Before drawing a data into a chart, state which scaling factor to use. State this for the x-axis and the y-axis separately. When drawing multiple data lines account for all values when rescaling. When drawing multiple lines into a chart, keep the scale consistent for all lines. ```json {"version":"net.nordeck.whiteboard@v1","whiteboard":{"slides":[{"elements":[{"position":{"x":200,"y":880},"type":"path","kind":"line","endMarker":"arrow-head-line","points":[{"x":0,"y":0},{"x":1520,"y":0}],"strokeColor":"#9e9e9e"},{"position":{"x":200,"y":200},"type":"path","kind":"line","endMarker":"arrow-head-line","points":[{"x":0,"y":680},{"x":0,"y":0}],"strokeColor":"#9e9e9e"},{"fillColor":"transparent","height":80,"position":{"x":200,"y":880},"type":"shape","kind":"rectangle","width":1520,"text":"Something specific to the team"},{"fillColor":"transparent","height":80,"position":{"x":200,"y":40},"type":"shape","kind":"rectangle","width":1520,"text":"How are you feeling?"},{"fillColor":"transparent","height":80,"position":{"x":0,"y":120},"type":"shape","kind":"rectangle","width":640,"text":"Satisfaction with the results"}]}]}} ``` This next whiteboard is almost identical to the previous template. Both axes have values written next to them which makes them suitable for accurate data where the scale is important. The x axes starts at 0 and ends at 100. The y axes starts at 1000 and ends at 2000. For most charts the axis will start at 0 but depending on the data it makes sense to start at a lower or higher value. The heading has the text "Train Speed Over Time". The x-axis is labeled "Time (seconds)". The y-axis is labeled "Distance (meters)". ```json {"version":"net.nordeck.whiteboard@v1","whiteboard":{"slides":[{"elements":[{"position":{"x":200,"y":880},"type":"path","kind":"line","endMarker":"arrow-head-line","points":[{"x":0,"y":0},{"x":1520,"y":0}],"strokeColor":"#9e9e9e"},{"position":{"x":200,"y":200},"type":"path","kind":"line","endMarker":"arrow-head-line","points":[{"x":0,"y":680},{"x":0,"y":0}],"strokeColor":"#9e9e9e"},{"fillColor":"transparent","height":80,"position":{"x":360,"y":880},"type":"shape","kind":"rectangle","width":1200,"text":"Time (seconds)"},{"fillColor":"transparent","height":80,"position":{"x":200,"y":40},"type":"shape","kind":"rectangle","width":1520,"text":"Train Speed Over Time"},{"fillColor":"transparent","height":80,"position":{"x":0,"y":120},"type":"shape","kind":"rectangle","width":640,"text":"Distance (meters)"},{"fillColor":"transparent","height":80,"position":{"x":200,"y":880},"type":"shape","kind":"rectangle","width":120,"text":"0"},{"fillColor":"transparent","height":80,"position":{"x":1600,"y":880},"type":"shape","kind":"rectangle","width":120,"text":"100"},{"fillColor":"transparent","height":80,"position":{"x":80,"y":200},"type":"shape","kind":"rectangle","width":120,"text":"2000"},{"fillColor":"transparent","height":80,"position":{"x":80,"y":800},"type":"shape","kind":"rectangle","width":120,"text":"1000"}]}]}} ``` ### Code This code validates elements of a whiteboard. ```ts /* * Copyright 2022 Nordeck IT + Consulting GmbH * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import Joi from 'joi'; import loglevel from 'loglevel'; // Do not import from the index file to prevent cyclic dependencies import { defaultAcceptedImageTypes } from '../../../components/ImageUpload/consts'; import { BoundingRect, calculateBoundingRectForPoints, Point, pointSchema, } from './point'; export type ElementBase = { type: string; position: Point; }; const elementBaseSchema = Joi.object({ type: Joi.string().required(), position: pointSchema.required(), }) .unknown() .required(); export type ShapeKind = 'rectangle' | 'circle' | 'ellipse' | 'triangle'; export type TextAlignment = 'left' | 'center' | 'right'; export type ShapeElement = ElementBase & { type: 'shape'; kind: ShapeKind; width: number; height: number; fillColor: string; strokeColor?: string; strokeWidth?: number; borderRadius?: number; text: string; textAlignment?: TextAlignment; textColor?: string; textBold?: boolean; textItalic?: boolean; }; const shapeElementSchema = elementBaseSchema .append({ type: Joi.string().valid('shape').required(), kind: Joi.string() .valid('rectangle', 'circle', 'ellipse', 'triangle') .required(), width: Joi.number().strict().required(), height: Joi.number().strict().required(), fillColor: Joi.string().required(), strokeColor: Joi.string().strict(), strokeWidth: Joi.number().strict(), borderRadius: Joi.number().strict(), text: Joi.string().min(0).required(), textAlignment: Joi.string().valid('left', 'center', 'right'), textColor: Joi.string().strict(), textBold: Joi.boolean(), textItalic: Joi.boolean(), }) .required(); export type PathKind = 'line' | 'polyline'; export type EndMarker = 'arrow-head-line'; export type PathElement = ElementBase & { type: 'path'; kind: PathKind; points: Point[]; strokeColor: string; endMarker?: EndMarker; }; const pathElementSchema = elementBaseSchema .append({ type: Joi.string().valid('path').required(), kind: Joi.string().valid('line', 'polyline').required(), points: Joi.array().items(pointSchema).required(), strokeColor: Joi.string().required(), endMarker: Joi.string().valid('arrow-head-line'), }) .required(); export type ImageMimeType = | 'image/gif' | 'image/jpeg' | 'image/png' | 'image/svg+xml'; export type ImageElement = ElementBase & { type: 'image'; width: number; height: number; /** * MXC URI of the image * {@link https://spec.matrix.org/v1.9/client-server-api/#matrix-content-mxc-uris} */ mxc: string; fileName: string; mimeType: ImageMimeType; }; const imageElementSchema = elementBaseSchema .append({ type: Joi.string().valid('image').required(), mxc: Joi.string() .regex(/mxc:\/\/.*\/.*/) .allow('') .required(), fileName: Joi.string().required(), mimeType: Joi.string() .valid(...Object.keys(defaultAcceptedImageTypes)) .required(), width: Joi.number().strict().required(), height: Joi.number().strict().required(), }) .required(); export type Element = ShapeElement | PathElement | ImageElement; export type ElementKind = ShapeKind | PathKind; export const elementSchema = Joi.alternatives().conditional('.type', [ { is: 'shape', then: shapeElementSchema }, { is: 'path', then: pathElementSchema }, { is: 'image', then: imageElementSchema }, ]); export function isValidElement(element: unknown): element is Element { const result = elementSchema.validate(element); if (result.error) { loglevel.error('Error while validating the element', result.error); return false; } return true; } export function calculateBoundingRectForElements( elements: Element[], ): BoundingRect { const element = elements.length === 1 ? elements[0] : undefined; const elementsBoundingRect = elements.length > 1 ? calculateBoundingRectForPoints( elements.flatMap((e) => e.type === 'path' ? e.points.map((p) => ({ x: e.position.x + p.x, y: e.position.y + p.y, })) : [ { x: e.position.x, y: e.position.y }, { x: e.position.x + e.width, y: e.position.y + e.height }, ], ), ) : undefined; const x = element?.position.x ?? elementsBoundingRect?.offsetX ?? 0; const y = element?.position.y ?? elementsBoundingRect?.offsetY ?? 0; const height = element?.type === 'path' ? calculateBoundingRectForPoints(element.points).height : (element?.height ?? elementsBoundingRect?.height ?? 0); const width = element?.type === 'path' ? calculateBoundingRectForPoints(element.points).width : (element?.width ?? elementsBoundingRect?.width ?? 0); return { offsetX: x, offsetY: y, width, height }; } export type Size = { width: number; height: number; }; /** * Calculate the size of an element so that it fits into a container. * Maintain the aspect ratio. * * @param elementSize - Element size * @param containerSize - Container size to fit the element into * @returns Fitted element size */ export function calculateFittedElementSize( elementSize: Size, containerSize: Size, ): Size { const resultSize = { ...elementSize }; if (elementSize.width > containerSize.width) { const scaleFactor = containerSize.width / elementSize.width; resultSize.width = containerSize.width; resultSize.height = Math.round(elementSize.height * scaleFactor); } if (elementSize.height > containerSize.height) { const scaleFactor = containerSize.height / elementSize.height; resultSize.height = containerSize.height; resultSize.width = Math.round(elementSize.width * scaleFactor); } return resultSize; } /** * Calculate the position of an element so that it is centred inside a container. * * @param element - Element containing bounding rectangle size * @param containerSize - Container size to centre the element inside * @returns Centred top left position */ export function calculateCentredPosition( element: Size, containerSize: Size, ): Point { return { x: Math.round((containerSize.width - element.width) / 2), y: Math.round((containerSize.height - element.height) / 2), }; } /** * @returns true, if the element is a shape without a background color, else false */ export function isTextShape(element: Element): boolean { return ( element.type === 'shape' && // a text shape does not have a background color (element.fillColor === '' || element.fillColor === 'transparent') && element.text.trim() !== '' ); } /** * @returns true, if the element is a shape with a background color, else false */ export function isShapeWithText(element: Element): boolean { return ( element.type === 'shape' && element.fillColor !== '' && element.fillColor !== 'transparent' && element.text.trim() !== '' ); } /** * @returns true if elements contain at least one shape without a background color an a text, else false */ export function includesTextShape(elements: Element[]): boolean { return elements.some(isTextShape); } /** * @returns true if elements contain at least one shape with a background color and a text, else false */ export function includesShapeWithText(elements: Element[]): boolean { return elements.some(isShapeWithText); } ```