blob: cd20c197635f9d0c25cbe80b5cfbc4415d64c213 [file] [log] [blame]
// 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),
};
}