UNPKG

chrome-devtools-frontend

Version:
894 lines (787 loc) • 32.7 kB
// Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import {type PathCommands, type Position, type Quad} from './common.js'; import { buildPath, createPathForQuad, drawPathWithLineStyle, emptyBounds, fillPathWithBoxStyle, hatchFillPath, type BoxStyle, type LineStyle, } from './highlight_common.js'; type FlexLinesData = FlexItemData[][]; interface FlexItemData { itemBorder: PathCommands; baseline: number; } export interface FlexContainerHighlight { containerBorder: PathCommands; lines: FlexLinesData; isHorizontalFlow: boolean; isReverse: boolean; alignItemsStyle: string; mainGap: number; crossGap: number; flexContainerHighlightConfig: { containerBorder?: LineStyle, lineSeparator?: LineStyle, itemSeparator?: LineStyle, mainDistributedSpace?: BoxStyle, crossDistributedSpace?: BoxStyle, rowGapSpace?: BoxStyle, columnGapSpace?: BoxStyle, crossAlignment?: LineStyle, }; } export interface FlexItemHighlight { baseSize: number; isHorizontalFlow: boolean; flexItemHighlightConfig: {baseSizeBox?: BoxStyle, baseSizeBorder?: LineStyle, flexibilityArrow?: LineStyle}; boxSizing: 'content'|'border'; } interface LineQuads { quad: Quad; items: Quad[]; extendedItems: Quad[]; } interface GapQuads { mainGaps: Quad[][]; crossGaps: Quad[]; } const ALIGNMENT_LINE_THICKNESS = 2; const ALIGNMENT_ARROW_BODY_HEIGHT = 5; const ALIGNMENT_ARROW_BODY_WIDTH = 5; const ALIGNMENT_ARROW_TIP_HEIGHT = 6; const ALIGNMENT_ARROW_TIP_WIDTH = 11; const ALIGNMENT_ARROW_DISTANCE_FROM_LINE = 2; const FLEXIBILITY_ARROW_THICKNESS = 1; const FLEXIBILITY_ARROW_TIP_SIZE = 5; export function drawLayoutFlexItemHighlight( highlight: FlexItemHighlight, itemPath: PathCommands, context: CanvasRenderingContext2D, deviceScaleFactor: number, canvasWidth: number, canvasHeight: number, emulationScaleFactor: number) { const {baseSize, isHorizontalFlow} = highlight; const itemQuad = rectPathToQuad(itemPath); const baseSizeQuad = isHorizontalFlow ? { p1: itemQuad.p1, p2: getColinearPointAtDistance(itemQuad.p1, itemQuad.p2, baseSize), p3: getColinearPointAtDistance(itemQuad.p4, itemQuad.p3, baseSize), p4: itemQuad.p4, } : { p1: itemQuad.p1, p2: itemQuad.p2, p3: getColinearPointAtDistance(itemQuad.p2, itemQuad.p3, baseSize), p4: getColinearPointAtDistance(itemQuad.p1, itemQuad.p4, baseSize), }; drawItemBaseSize(highlight, itemQuad, baseSizeQuad, context, emulationScaleFactor); drawFlexibilityArrow(highlight, itemQuad, baseSizeQuad, context, emulationScaleFactor); } function drawItemBaseSize( highlight: FlexItemHighlight, itemQuad: Quad, baseSizeQuad: Quad, context: CanvasRenderingContext2D, emulationScaleFactor: number) { const config = highlight.flexItemHighlightConfig; const bounds = emptyBounds(); const path = buildPath(quadToPath(baseSizeQuad), bounds, emulationScaleFactor); // Fill the base size box. const angle = Math.atan2(itemQuad.p4.y - itemQuad.p1.y, itemQuad.p4.x - itemQuad.p1.x) + (Math.PI * 45 / 180); fillPathWithBoxStyle(context, path, bounds, angle, config.baseSizeBox); // Draw the base size border. drawPathWithLineStyle(context, path, config.baseSizeBorder); } function drawFlexibilityArrow( highlight: FlexItemHighlight, itemQuad: Quad, baseSizeQuad: Quad, context: CanvasRenderingContext2D, emulationScaleFactor: number) { const {isHorizontalFlow} = highlight; const config = highlight.flexItemHighlightConfig; if (!config.flexibilityArrow) { return; } // Figure out where the arrow should start and end. const from = isHorizontalFlow ? { x: (baseSizeQuad.p2.x + baseSizeQuad.p3.x) / 2, y: (baseSizeQuad.p2.y + baseSizeQuad.p3.y) / 2, } : { x: (baseSizeQuad.p4.x + baseSizeQuad.p3.x) / 2, y: (baseSizeQuad.p4.y + baseSizeQuad.p3.y) / 2, }; const to = isHorizontalFlow ? { x: (itemQuad.p2.x + itemQuad.p3.x) / 2, y: (itemQuad.p2.y + itemQuad.p3.y) / 2, } : { x: (itemQuad.p4.x + itemQuad.p3.x) / 2, y: (itemQuad.p4.y + itemQuad.p3.y) / 2, }; if (to.x === from.x && to.y === from.y) { return; } // Draw the arrow line. const path = segmentToPath([from, to]); drawPathWithLineStyle( context, buildPath(path, emptyBounds(), emulationScaleFactor), config.flexibilityArrow, FLEXIBILITY_ARROW_THICKNESS); if (!config.flexibilityArrow.color) { return; } // Draw the tip of the arrow. const tipPath = buildPath( [ 'M', to.x - FLEXIBILITY_ARROW_TIP_SIZE, to.y - FLEXIBILITY_ARROW_TIP_SIZE, 'L', to.x, to.y, 'L', to.x - FLEXIBILITY_ARROW_TIP_SIZE, to.y + FLEXIBILITY_ARROW_TIP_SIZE, ], emptyBounds(), emulationScaleFactor); const angle = Math.atan2(to.y - from.y, to.x - from.x); context.save(); context.translate(to.x + .5, to.y + .5); context.rotate(angle); context.translate(-to.x - .5, -to.y - .5); drawPathWithLineStyle(context, tipPath, config.flexibilityArrow, FLEXIBILITY_ARROW_THICKNESS); context.restore(); } export function drawLayoutFlexContainerHighlight( highlight: FlexContainerHighlight, context: CanvasRenderingContext2D, deviceScaleFactor: number, canvasWidth: number, canvasHeight: number, emulationScaleFactor: number) { const config = highlight.flexContainerHighlightConfig; const bounds = emptyBounds(); const borderPath = buildPath(highlight.containerBorder, bounds, emulationScaleFactor); const {isHorizontalFlow, isReverse, lines} = highlight; drawPathWithLineStyle(context, borderPath, config.containerBorder); // If there are no lines, bail out now. if (!lines || !lines.length) { return; } // Process the item paths we received from the backend into quads we can use to draw what we need. const lineQuads = getLinesAndItemsQuads(highlight.containerBorder, lines, isHorizontalFlow, isReverse); // Draw lines and items. drawFlexLinesAndItems(highlight, context, emulationScaleFactor, lineQuads, isHorizontalFlow); // Draw the hatching pattern outside of items. drawFlexSpace(highlight, context, emulationScaleFactor, highlight.containerBorder, lineQuads); // Draw the self-alignment lines and arrows. drawFlexAlignment( highlight, context, emulationScaleFactor, lineQuads, lines.map(line => line.map(item => item.baseline))); } function drawFlexLinesAndItems( highlight: FlexContainerHighlight, context: CanvasRenderingContext2D, emulationScaleFactor: number, lineQuads: LineQuads[], isHorizontalFlow: boolean) { const config = highlight.flexContainerHighlightConfig; const paths = lineQuads.map((line, lineIndex) => { const nextLineQuad = lineQuads[lineIndex + 1] && lineQuads[lineIndex + 1].quad; return { path: isHorizontalFlow ? quadToHorizontalLinesPath(line.quad, nextLineQuad) : quadToVerticalLinesPath(line.quad, nextLineQuad), items: line.extendedItems.map((item, itemIndex) => { const nextItemQuad = line.extendedItems[itemIndex + 1] && line.extendedItems[itemIndex + 1]; return isHorizontalFlow ? quadToVerticalLinesPath(item, nextItemQuad) : quadToHorizontalLinesPath(item, nextItemQuad); }), }; }); // Only draw lines when there's more than 1. const drawLines = paths.length > 1; for (const {path, items} of paths) { for (const itemPath of items) { drawPathWithLineStyle(context, buildPath(itemPath, emptyBounds(), emulationScaleFactor), config.itemSeparator); } if (drawLines) { drawPathWithLineStyle(context, buildPath(path, emptyBounds(), emulationScaleFactor), config.lineSeparator); } } } /** * Draw the hatching pattern in all of the empty space between items and lines (either due to gaps or content * distribution). * Space created by content distribution along the cross axis (align-content) appears between flex lines. * Space created by content distribution along the main axis (justify-content) appears between flex items. * Space created by gap along the cross axis appears between flex lines. * Space created by gap along the main axis appears between flex items. */ function drawFlexSpace( highlight: FlexContainerHighlight, context: CanvasRenderingContext2D, emulationScaleFactor: number, container: PathCommands, lineQuads: LineQuads[]) { const {isHorizontalFlow} = highlight; const {mainDistributedSpace, crossDistributedSpace, rowGapSpace, columnGapSpace} = highlight.flexContainerHighlightConfig; const mainGapSpace = isHorizontalFlow ? columnGapSpace : rowGapSpace; const crossGapSpace = isHorizontalFlow ? rowGapSpace : columnGapSpace; const drawMainSpace = mainDistributedSpace && Boolean(mainDistributedSpace.fillColor || mainDistributedSpace.hatchColor); const drawCrossSpace = lineQuads.length > 1 && crossDistributedSpace && Boolean(crossDistributedSpace.fillColor || crossDistributedSpace.hatchColor); const drawMainGapSpace = mainGapSpace && Boolean(mainGapSpace.fillColor || mainGapSpace.hatchColor); const drawCrossGapSpace = lineQuads.length > 1 && crossGapSpace && Boolean(crossGapSpace.fillColor || crossGapSpace.hatchColor); const isSameStyle = mainDistributedSpace && crossDistributedSpace && mainGapSpace && crossGapSpace && mainDistributedSpace.fillColor === crossDistributedSpace.fillColor && mainDistributedSpace.hatchColor === crossDistributedSpace.hatchColor && mainDistributedSpace.fillColor === mainGapSpace.fillColor && mainDistributedSpace.hatchColor === mainGapSpace.hatchColor && mainDistributedSpace.fillColor === crossGapSpace.fillColor && mainDistributedSpace.hatchColor === crossGapSpace.hatchColor; const containerQuad = rectPathToQuad(container); // Start with the case where we want to draw all types of space, with the same style. This is important because it's // a common case that we can optimize by drawing in one go, and therefore avoiding having visual offsets between // mutliple hatch patterns. if (isSameStyle) { // Draw in one go by constructing a path that covers the entire container but punches holes where items are. const allItemQuads = lineQuads.map(line => line.extendedItems).flat().map(item => item); drawFlexSpaceInQuad(containerQuad, allItemQuads, mainDistributedSpace, context, emulationScaleFactor); return; } // Compute quads for the gaps between lines and items, if any. This will be useful when drawing the flex space. const gapQuads = getGapQuads(highlight, lineQuads); if (drawCrossSpace) { // For cross-space we draw a path that covers everything. const quadsToClip = [ // But we clip holes where lines are. ...lineQuads.map(line => line.quad), // And also clip holds where gaps are, if those are also drawn. ...(drawCrossGapSpace ? gapQuads.crossGaps : []), ]; drawFlexSpaceInQuad(containerQuad, quadsToClip, crossDistributedSpace, context, emulationScaleFactor); } if (drawMainSpace) { // Main space is draw per flex line. for (const [index, line] of lineQuads.entries()) { // For main-space, we draw a path that covers each line. const quadsToClip = [ // But we clip holes were items on the lines are. ...line.extendedItems, // And where gaps are, if those are also drawn. ...(drawMainGapSpace ? gapQuads.mainGaps[index] : []), ]; drawFlexSpaceInQuad(line.quad, quadsToClip, mainDistributedSpace, context, emulationScaleFactor); } } if (drawCrossGapSpace) { for (const quad of gapQuads.crossGaps) { drawFlexSpaceInQuad(quad, [], crossGapSpace, context, emulationScaleFactor); } } if (drawMainGapSpace) { for (const line of gapQuads.mainGaps) { for (const quad of line) { drawFlexSpaceInQuad(quad, [], mainGapSpace, context, emulationScaleFactor); } } } } function drawFlexAlignment( highlight: FlexContainerHighlight, context: CanvasRenderingContext2D, emulationScaleFactor: number, lineQuads: LineQuads[], itemBaselines: number[][]) { lineQuads.forEach(({quad, items}, i) => { drawFlexAlignmentForLine(highlight, context, emulationScaleFactor, quad, items, itemBaselines[i]); }); } function drawFlexAlignmentForLine( highlight: FlexContainerHighlight, context: CanvasRenderingContext2D, emulationScaleFactor: number, lineQuad: Quad, itemQuads: Quad[], itemBaselines: number[]) { const {alignItemsStyle, isHorizontalFlow} = highlight; const {crossAlignment} = highlight.flexContainerHighlightConfig; if (!crossAlignment || !crossAlignment.color) { return; } // Note that the order of the 2 points in the array matters as it is used to determine where the arrow will be drawn. // // first second // point point // o--------------------o // ^ // | // arrow // // // arrow // second | first // point V point // o--------------------o const linesToDraw: [Position, Position][] = []; switch (alignItemsStyle) { case 'flex-start': linesToDraw.push([ isHorizontalFlow ? lineQuad.p1 : lineQuad.p4, isHorizontalFlow ? lineQuad.p2 : lineQuad.p1, ]); break; case 'flex-end': linesToDraw.push([ isHorizontalFlow ? lineQuad.p3 : lineQuad.p2, isHorizontalFlow ? lineQuad.p4 : lineQuad.p3, ]); break; case 'center': if (isHorizontalFlow) { linesToDraw.push([ { x: (lineQuad.p1.x + lineQuad.p4.x) / 2, y: (lineQuad.p1.y + lineQuad.p4.y) / 2, }, { x: (lineQuad.p2.x + lineQuad.p3.x) / 2, y: (lineQuad.p2.y + lineQuad.p3.y) / 2, }, ]); linesToDraw.push([ { x: (lineQuad.p2.x + lineQuad.p3.x) / 2, y: (lineQuad.p2.y + lineQuad.p3.y) / 2, }, { x: (lineQuad.p1.x + lineQuad.p4.x) / 2, y: (lineQuad.p1.y + lineQuad.p4.y) / 2, }, ]); } else { linesToDraw.push([ { x: (lineQuad.p1.x + lineQuad.p2.x) / 2, y: (lineQuad.p1.y + lineQuad.p2.y) / 2, }, { x: (lineQuad.p3.x + lineQuad.p4.x) / 2, y: (lineQuad.p3.y + lineQuad.p4.y) / 2, }, ]); linesToDraw.push([ { x: (lineQuad.p3.x + lineQuad.p4.x) / 2, y: (lineQuad.p3.y + lineQuad.p4.y) / 2, }, { x: (lineQuad.p1.x + lineQuad.p2.x) / 2, y: (lineQuad.p1.y + lineQuad.p2.y) / 2, }, ]); } break; case 'stretch': case 'normal': linesToDraw.push([ isHorizontalFlow ? lineQuad.p1 : lineQuad.p4, isHorizontalFlow ? lineQuad.p2 : lineQuad.p1, ]); linesToDraw.push([ isHorizontalFlow ? lineQuad.p3 : lineQuad.p2, isHorizontalFlow ? lineQuad.p4 : lineQuad.p3, ]); break; case 'baseline': // Baseline alignment only works in horizontal direction. if (isHorizontalFlow) { // We know the baseline for each item, it's an offset value from the top of the item's quad box. // If align-items:baseline is applied to the container, then all of the items' baselines are aligned and we can // just use the first item's baseline to draw the alignment line we need. // Any item may, however, override its own self-alignment with align-self. We don't know if some items are // aligned differently, or if no items at all inherit from the container's align-items:baseline property, so in // theory, drawing the alignment line is impossible. // That said, in situations where align-items:baseline is used, it is safe to assume that most (if not all) of // the items are actually using this alignment value. // Given this, we still draw the alignment line using the first item's baseline value. const itemQuad = itemQuads[0]; const start = intersectSegments([itemQuad.p1, itemQuad.p2], [lineQuad.p2, lineQuad.p3]); const end = intersectSegments([itemQuad.p1, itemQuad.p2], [lineQuad.p1, lineQuad.p4]); const baseline = itemBaselines[0]; const angle = Math.atan2(itemQuad.p4.y - itemQuad.p1.y, itemQuad.p4.x - itemQuad.p1.x); linesToDraw.push([ { x: start.x + (baseline * Math.cos(angle)), y: start.y + (baseline * Math.sin(angle)), }, { x: end.x + (baseline * Math.cos(angle)), y: end.y + (baseline * Math.sin(angle)), }, ]); } break; } for (const points of linesToDraw) { const path = segmentToPath(points); drawPathWithLineStyle( context, buildPath(path, emptyBounds(), emulationScaleFactor), crossAlignment, ALIGNMENT_LINE_THICKNESS); drawAlignmentArrow(highlight, context, emulationScaleFactor, points[0], points[1]); } } /** * Draw an arrow pointed at the middle of a segment. The segment isn't necessarily vertical or horizontal. * * start C end * o-------------x--------------o * / \ * / \ * /_ _\ * | | * |_| */ function drawAlignmentArrow( highlight: FlexContainerHighlight, context: CanvasRenderingContext2D, emulationScaleFactor: number, startPoint: Position, endPoint: Position) { const {crossAlignment} = highlight.flexContainerHighlightConfig; if (!crossAlignment || !crossAlignment.color) { return; } // The angle of the segment. const angle = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x); // Where the tip of the arrow meets the segment, plus some offset so they don't overlap. const C = { x: (-ALIGNMENT_ARROW_DISTANCE_FROM_LINE * Math.cos(angle - .5 * Math.PI)) + ((startPoint.x + endPoint.x) / 2), y: (-ALIGNMENT_ARROW_DISTANCE_FROM_LINE * Math.sin(angle - .5 * Math.PI)) + ((startPoint.y + endPoint.y) / 2), }; const path = buildPath( [ 'M', C.x, C.y, 'L', C.x + (ALIGNMENT_ARROW_TIP_WIDTH / 2), C.y + ALIGNMENT_ARROW_TIP_HEIGHT, 'L', C.x + (ALIGNMENT_ARROW_BODY_WIDTH / 2), C.y + ALIGNMENT_ARROW_TIP_HEIGHT, 'L', C.x + (ALIGNMENT_ARROW_BODY_WIDTH / 2), C.y + ALIGNMENT_ARROW_TIP_HEIGHT + ALIGNMENT_ARROW_BODY_HEIGHT, 'L', C.x - (ALIGNMENT_ARROW_BODY_WIDTH / 2), C.y + ALIGNMENT_ARROW_TIP_HEIGHT + ALIGNMENT_ARROW_BODY_HEIGHT, 'L', C.x - (ALIGNMENT_ARROW_BODY_WIDTH / 2), C.y + ALIGNMENT_ARROW_TIP_HEIGHT, 'L', C.x - (ALIGNMENT_ARROW_TIP_WIDTH / 2), C.y + ALIGNMENT_ARROW_TIP_HEIGHT, 'Z', ], emptyBounds(), emulationScaleFactor); context.save(); context.translate(C.x, C.y); context.rotate(angle); context.translate(-C.x, -C.y); context.fillStyle = crossAlignment.color; context.fill(path); context.lineWidth = 1; context.strokeStyle = 'white'; context.stroke(path); context.restore(); } function drawFlexSpaceInQuad( outerQuad: Quad, quadsToClip: Quad[], boxStyle: BoxStyle|undefined, context: CanvasRenderingContext2D, emulationScaleFactor: number) { if (!boxStyle) { return; } if (boxStyle.fillColor) { const bounds = emptyBounds(); const path = createPathForQuad(outerQuad, quadsToClip, bounds, emulationScaleFactor); context.fillStyle = boxStyle.fillColor; context.fill(path); } if (boxStyle.hatchColor) { const angle = Math.atan2(outerQuad.p2.y - outerQuad.p1.y, outerQuad.p2.x - outerQuad.p1.x) * 180 / Math.PI; const bounds = emptyBounds(); const path = createPathForQuad(outerQuad, quadsToClip, bounds, emulationScaleFactor); hatchFillPath(context, path, bounds, 10, boxStyle.hatchColor, angle, false); } } /** * We get a list of paths for each flex item from the backend. From this list, we compute the resulting paths for each * flex line too (making it span the entire container size (in the main direction)). We also process the item path so * they span the entire flex line size (in the cross direction). * * @param container * @param lines * @param isHorizontalFlow */ export function getLinesAndItemsQuads( container: PathCommands, lines: FlexLinesData, isHorizontalFlow: boolean, isReverse: boolean): LineQuads[] { const containerQuad = rectPathToQuad(container); // Create a quad for each line that's as big as the items it contains and extends to the edges of the container in the // main direction. const lineQuads: LineQuads[] = []; for (const line of lines) { if (!line.length) { continue; } let lineQuad = rectPathToQuad(line[0].itemBorder); const itemQuads: Quad[] = []; for (const {itemBorder} of line) { const itemQuad = rectPathToQuad(itemBorder); lineQuad = !lineQuad ? itemQuad : uniteQuads(lineQuad, itemQuad, isHorizontalFlow, isReverse); itemQuads.push(itemQuad); } const extendedLineQuad = lines.length === 1 ? containerQuad : growQuadToEdgesOf(lineQuad, containerQuad, isHorizontalFlow); const extendItemQuads = itemQuads.map(itemQuad => growQuadToEdgesOf(itemQuad, extendedLineQuad, !isHorizontalFlow)); lineQuads.push({ quad: extendedLineQuad, items: itemQuads, extendedItems: extendItemQuads, }); } return lineQuads; } export function getGapQuads( highlight: Pick<FlexContainerHighlight, 'crossGap'|'mainGap'|'isHorizontalFlow'|'isReverse'>, lineQuads: LineQuads[]): GapQuads { const {crossGap, mainGap, isHorizontalFlow, isReverse} = highlight; const mainGaps: Quad[][] = []; const crossGaps: Quad[] = []; if (crossGap && lineQuads.length > 1) { for (let i = 0, j = i + 1; i < lineQuads.length - 1; i++, j = i + 1) { const line1 = lineQuads[i].quad; const line2 = lineQuads[j].quad; crossGaps.push(getGapQuadBetweenQuads(line1, line2, crossGap, isHorizontalFlow)); } } for (const {extendedItems} of lineQuads) { const lineGapQuads = []; if (mainGap) { for (let i = 0, j = i + 1; i < extendedItems.length - 1; i++, j = i + 1) { const item1 = extendedItems[i]; const item2 = extendedItems[j]; lineGapQuads.push(getGapQuadBetweenQuads(item1, item2, mainGap, !isHorizontalFlow, isReverse)); } } mainGaps.push(lineGapQuads); } return {mainGaps, crossGaps}; } /** * Create a quad for the gap that exists between 2 quads. * * +-------+ +-+ +-------+ * | quad1 | |/| | quad2 | * +-------+ +-+ +-------+ * gap quad * * @param quad1 * @param quad2 * @param size The size of the gap between the 2 quads * @param vertically whether the 2 quads are stacked vertically (quad1 above quad2), or horizontally (quad1 left of * quad2) * @param isReverse whether the direction is reversed (quad1 below quad2 or quad1 right of quad2) */ export function getGapQuadBetweenQuads( quad1: Quad, quad2: Quad, size: number, vertically: boolean, isReverse?: boolean) { if (isReverse) { [quad1, quad2] = [quad2, quad1]; } const angle = vertically ? Math.atan2(quad1.p4.y - quad1.p1.y, quad1.p4.x - quad1.p1.x) : Math.atan2(quad1.p2.y - quad1.p1.y, quad1.p2.x - quad1.p1.x); const d = vertically ? distance(quad1.p4, quad2.p1) : distance(quad1.p2, quad2.p1); const startOffset = (d / 2) - (size / 2); const endOffset = (d / 2) + (size / 2); return vertically ? { p1: { x: Math.round(quad1.p4.x + (startOffset * Math.cos(angle))), y: Math.round(quad1.p4.y + (startOffset * Math.sin(angle))), }, p2: { x: Math.round(quad1.p3.x + (startOffset * Math.cos(angle))), y: Math.round(quad1.p3.y + (startOffset * Math.sin(angle))), }, p3: { x: Math.round(quad1.p3.x + (endOffset * Math.cos(angle))), y: Math.round(quad1.p3.y + (endOffset * Math.sin(angle))), }, p4: { x: Math.round(quad1.p4.x + (endOffset * Math.cos(angle))), y: Math.round(quad1.p4.y + (endOffset * Math.sin(angle))), }, } : { p1: { x: Math.round(quad1.p2.x + (startOffset * Math.cos(angle))), y: Math.round(quad1.p2.y + (startOffset * Math.sin(angle))), }, p2: { x: Math.round(quad1.p2.x + (endOffset * Math.cos(angle))), y: Math.round(quad1.p2.y + (endOffset * Math.sin(angle))), }, p3: { x: Math.round(quad1.p3.x + (endOffset * Math.cos(angle))), y: Math.round(quad1.p3.y + (endOffset * Math.sin(angle))), }, p4: { x: Math.round(quad1.p3.x + (startOffset * Math.cos(angle))), y: Math.round(quad1.p3.y + (startOffset * Math.sin(angle))), }, }; } function quadToHorizontalLinesPath(quad: Quad, nextQuad: Quad|undefined): PathCommands { const skipEndLine = nextQuad && quad.p4.y === nextQuad.p1.y; const startLine = ['M', quad.p1.x, quad.p1.y, 'L', quad.p2.x, quad.p2.y]; return skipEndLine ? startLine : [...startLine, 'M', quad.p3.x, quad.p3.y, 'L', quad.p4.x, quad.p4.y]; } function quadToVerticalLinesPath(quad: Quad, nextQuad: Quad|undefined): PathCommands { const skipEndLine = nextQuad && quad.p2.x === nextQuad.p1.x; const startLine = ['M', quad.p1.x, quad.p1.y, 'L', quad.p4.x, quad.p4.y]; return skipEndLine ? startLine : [...startLine, 'M', quad.p3.x, quad.p3.y, 'L', quad.p2.x, quad.p2.y]; } function quadToPath(quad: Quad): PathCommands { return [ 'M', quad.p1.x, quad.p1.y, 'L', quad.p2.x, quad.p2.y, 'L', quad.p3.x, quad.p3.y, 'L', quad.p4.x, quad.p4.y, 'Z', ]; } function segmentToPath(segment: [Position, Position]): PathCommands { return ['M', segment[0].x, segment[0].y, 'L', segment[1].x, segment[1].y]; } /** * Transform a path array (as returned by the backend) that corresponds to a rectangle into a quad. * @param commands * @return The quad object */ function rectPathToQuad(commands: PathCommands): Quad { return { p1: {x: commands[1] as number, y: commands[2] as number}, p2: {x: commands[4] as number, y: commands[5] as number}, p3: {x: commands[7] as number, y: commands[8] as number}, p4: {x: commands[10] as number, y: commands[11] as number}, }; } /** * Get a quad that bounds the provided 2 quads. * This only works if both quads have their respective sides parallel to eachother. * Note that it is more complicated because rectangles can be transformed (i.e. their sides aren't necessarily parallel * to the x and y axes). * @param quad1 * @param quad2 * @param isHorizontalFlow * @param isReverse */ export function uniteQuads(quad1: Quad, quad2: Quad, isHorizontalFlow: boolean, isReverse: boolean): Quad { if (isReverse) { [quad1, quad2] = [quad2, quad1]; } const mainStartSegment = isHorizontalFlow ? [quad1.p1, quad1.p4] : [quad1.p1, quad1.p2]; const mainEndSegment = isHorizontalFlow ? [quad2.p2, quad2.p3] : [quad2.p4, quad2.p3]; const crossStartSegment1 = isHorizontalFlow ? [quad1.p1, quad1.p2] : [quad1.p1, quad1.p4]; const crossEndSegment1 = isHorizontalFlow ? [quad1.p4, quad1.p3] : [quad1.p2, quad1.p3]; const crossStartSegment2 = isHorizontalFlow ? [quad2.p1, quad2.p2] : [quad2.p1, quad2.p4]; const crossEndSegment2 = isHorizontalFlow ? [quad2.p4, quad2.p3] : [quad2.p2, quad2.p3]; let p1, p2, p3, p4; if (isHorizontalFlow) { p1 = intersectSegments(mainStartSegment, crossStartSegment2); if (segmentContains(mainStartSegment, p1)) { p1 = quad1.p1; } p2 = intersectSegments(mainEndSegment, crossStartSegment1); if (segmentContains(mainEndSegment, p2)) { p2 = quad2.p2; } p3 = intersectSegments(mainEndSegment, crossEndSegment1); if (segmentContains(mainEndSegment, p3)) { p3 = quad2.p3; } p4 = intersectSegments(mainStartSegment, crossEndSegment2); if (segmentContains(mainStartSegment, p4)) { p4 = quad1.p4; } } else { p1 = intersectSegments(mainStartSegment, crossStartSegment2); if (segmentContains(mainStartSegment, p1)) { p1 = quad1.p1; } p2 = intersectSegments(mainStartSegment, crossEndSegment2); if (segmentContains(mainStartSegment, p2)) { p2 = quad1.p2; } p3 = intersectSegments(mainEndSegment, crossEndSegment1); if (segmentContains(mainEndSegment, p3)) { p3 = quad2.p3; } p4 = intersectSegments(mainEndSegment, crossStartSegment1); if (segmentContains(mainEndSegment, p4)) { p4 = quad2.p4; } } return {p1, p2, p3, p4}; } /** * Given 2 quads, with one being contained inside the other, grow the inner one, along one direction, so it ends up * flush aginst the outer one. * @param innerQuad * @param outerQuad * @param horizontally The direction to grow the inner quad along */ export function growQuadToEdgesOf(innerQuad: Quad, outerQuad: Quad, horizontally: boolean): Quad { return { p1: horizontally ? intersectSegments([outerQuad.p1, outerQuad.p4], [innerQuad.p1, innerQuad.p2]) : intersectSegments([outerQuad.p1, outerQuad.p2], [innerQuad.p1, innerQuad.p4]), p2: horizontally ? intersectSegments([outerQuad.p2, outerQuad.p3], [innerQuad.p1, innerQuad.p2]) : intersectSegments([outerQuad.p1, outerQuad.p2], [innerQuad.p2, innerQuad.p3]), p3: horizontally ? intersectSegments([outerQuad.p2, outerQuad.p3], [innerQuad.p3, innerQuad.p4]) : intersectSegments([outerQuad.p3, outerQuad.p4], [innerQuad.p2, innerQuad.p3]), p4: horizontally ? intersectSegments([outerQuad.p1, outerQuad.p4], [innerQuad.p3, innerQuad.p4]) : intersectSegments([outerQuad.p3, outerQuad.p4], [innerQuad.p1, innerQuad.p4]), }; } /** * Return the x/y intersection of the 2 segments * @param segment1 * @param segment2 * @return the point where the segments intersect */ export function intersectSegments([p1, p2]: Position[], [p3, p4]: Position[]): Position { const x = (((p1.x * p2.y - p1.y * p2.x) * (p3.x - p4.x)) - ((p1.x - p2.x) * (p3.x * p4.y - p3.y * p4.x))) / (((p1.x - p2.x) * (p3.y - p4.y)) - (p1.y - p2.y) * (p3.x - p4.x)); const y = (((p1.x * p2.y - p1.y * p2.x) * (p3.y - p4.y)) - ((p1.y - p2.y) * (p3.x * p4.y - p3.y * p4.x))) / (((p1.x - p2.x) * (p3.y - p4.y)) - (p1.y - p2.y) * (p3.x - p4.x)); return { x: Object.is(x, -0) ? 0 : x, y: Object.is(y, -0) ? 0 : y, }; } /** * Does the provided segment contain the provided point * @param segment * @param point */ export function segmentContains([p1, p2]: Position[], point: Position): boolean { if (p1.x < p2.x && (point.x < p1.x || point.x > p2.x)) { return false; } if (p1.x > p2.x && (point.x > p1.x || point.x < p2.x)) { return false; } if (p1.y < p2.y && (point.y < p1.y || point.y > p2.y)) { return false; } if (p1.y > p2.y && (point.y > p1.y || point.y < p2.y)) { return false; } return (point.y - p1.y) * (p2.x - p1.x) === (p2.y - p1.y) * (point.x - p1.x); } export function distance(p1: Position, p2: Position) { return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } export function getColinearPointAtDistance(p1: Position, p2: Position, distance: number): Position { const slope = (p2.y - p1.y) / (p2.x - p1.x); const angle = Math.atan(slope); return { x: p1.x + distance * Math.cos(angle), y: p1.y + distance * Math.sin(angle), }; }