UNPKG

dotting

Version:

Dotting is a pixel art editor component library for react

1,302 lines (1,289 loc) 353 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var React = require('react'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var React__default = /*#__PURE__*/_interopDefaultLegacy(React); const DefaultPanZoom = { scale: 1, offset: { x: 0, y: 0 }, }; const DefaultGridSquareLength = 20; const DefaultButtonHeight = 30; const DefaultButtonMargin = DefaultButtonHeight / 2; const MaxImageBitMapSideLength = 2500; var ButtonDirection; (function (ButtonDirection) { ButtonDirection["TOP"] = "TOP"; ButtonDirection["BOTTOM"] = "BOTTOM"; ButtonDirection["LEFT"] = "LEFT"; ButtonDirection["RIGHT"] = "RIGHT"; ButtonDirection["TOPLEFT"] = "TOPLEFT"; ButtonDirection["TOPRIGHT"] = "TOPRIGHT"; ButtonDirection["BOTTOMLEFT"] = "BOTTOMLEFT"; ButtonDirection["BOTTOMRIGHT"] = "BOTTOMRIGHT"; })(ButtonDirection || (ButtonDirection = {})); const DefaultPixelDataDimensions = { columnCount: 10, rowCount: 10, }; var MouseMode; (function (MouseMode) { MouseMode["PANNING"] = "PANNING"; MouseMode["PINCHZOOMING"] = "PINCHZOOMING"; MouseMode["EXTENDING"] = "EXTENDING"; MouseMode["DRAWING"] = "DRAWING"; MouseMode["NULL"] = "NULL"; })(MouseMode || (MouseMode = {})); const DefaultZoomSensitivity = 200; const DefaultMaxScale = 1.5; const DefaultMinScale = 0.3; const CurrentDeviceUserId = "current-device-user-id"; const InteractionExtensionAllowanceRatio = 2; const InteractionEdgeTouchingRange = 6; const DashedLineOffsetFromPixelCanvas = 15; const ExtensionGuideCircleRadius = 3; const DefaultExtendArrowPadding = 2; const DefaultBackgroundColor = "#999999"; const DefaultPixelColor = "#ffffff"; const GridMinimumScale = 0.2; const MinColumnOrRowCount = 2; // the grid size should be at least 2x2 class DottingError extends Error { constructor(message) { super(message); this.name = "DottingError"; } } class DuplicateLayerIdError extends DottingError { constructor(layerId) { super(`Duplicate layer id ${layerId}. Please make sure all layer ids are unique.`); this.name = "DuplicateLayerIdError"; } } class InvalidSquareDataError extends DottingError { constructor(layerId) { const message = layerId ? ` for layer ${layerId}` : ""; super(`Invalid square data${message}. Please make sure all data have the same row and column count.`); this.name = "InvalidSquareDataError"; } } class InvalidDataDimensionsError extends DottingError { constructor(layerId) { const message = layerId ? ` for layer ${layerId}` : ""; super(`Invalid data dimensions${message}. Please make sure all data have the same dimensions.`); this.name = "InvalidDataDimensionsError"; } } class InvalidDataIndicesError extends DottingError { constructor(layerId) { const message = layerId ? ` for layer ${layerId}` : ""; super(`Invalid data indices${message}. Please make sure all data have the same topRowIndex and leftColumnIndex.`); this.name = "InvalidDataIndicesError"; } } class UnspecifiedLayerIdError extends DottingError { constructor() { super(`Layer id has not been specified`); this.name = "UnspecifiedLayerIdError"; } } class LayerNotFoundError extends DottingError { constructor(layerId) { super(`Layer ${layerId} not found.`); this.name = "LayerNotFoundError"; } } class UnrecognizedDownloadOptionError extends DottingError { constructor() { super(`Unrecognized download option.`); this.name = "UnrecognizedDownloadOptionError"; } } class NoDataToMakeSvgError extends DottingError { constructor() { super(`No data to make svg.`); this.name = "NoDataToMakeSvgError"; } } // eslint-disable-next-line @typescript-eslint/no-unused-vars const convertCartesianToScreen = (canvas, cartesianCoord, dpr) => { const screenPoint = { x: cartesianCoord.x + canvas.width / dpr / 2, y: cartesianCoord.y + canvas.height / dpr / 2, }; return screenPoint; }; function diffPoints(p1, p2) { return { x: p1.x - p2.x, y: p1.y - p2.y }; } function addPoints(p1, p2) { return { x: p1.x + p2.x, y: p1.y + p2.y }; } /** * Actual world point is converted to screen(=viewing) point * @param point * @param panZoom * @returns */ function getScreenPoint(point, panZoom) { const { offset, scale } = panZoom; return { x: Math.floor(point.x * scale + offset.x), y: Math.floor(point.y * scale + offset.y), }; } /** * This is the real point in the actual world * @param point * @param panZoom * @returns */ function getWorldPoint(point, panZoom) { const { offset, scale } = panZoom; return { x: (point.x - offset.x) / scale, y: (point.y - offset.y) / scale }; } function lerpRanges(value, range1Start, range1End, range2Start, range2End) { const ratio = (value - range1Start) / (range1End - range1Start); return range2Start + (range2End - range2Start) * ratio; } function getBresenhamLineIndices(x1, y1, x2, y2) { const startPosition = { x1, y1 }; const endPosition = { x2, y2 }; const width = x2 - x1; const height = y2 - y1; const isGradualSlope = Math.abs(width) >= Math.abs(height); const directionX = width >= 0 ? 1 : -1; const directionY = height >= 0 ? 1 : -1; const fw = directionX * width; const fh = directionY * height; let f = isGradualSlope ? fh * 2 - fw : 2 * fw - fh; const f1 = isGradualSlope ? 2 * fh : 2 * fw; const f2 = isGradualSlope ? 2 * (fh - fw) : 2 * (fw - fh); let x = startPosition.x1; let y = startPosition.y1; const missingPoints = []; if (isGradualSlope) { while (x != endPosition.x2) { missingPoints.push({ rowIndex: x, columnIndex: y }); if (f < 0) { f += f1; } else { f += f2; y += directionY; } x += directionX; } } else { while (y != endPosition.y2) { missingPoints.push({ rowIndex: x, columnIndex: y }); if (f < 0) { f += f1; } else { f += f2; x += directionX; } y += directionY; } } return missingPoints; } function getBresenhamEllipseIndices(x1, y1, x2, y2, filled) { const points = []; let a = Math.abs(x2 - x1); const b = Math.abs(y2 - y1); let b1 = b & 1; let dx = 4 * (1 - a) * b * b; let dy = 4 * (b1 + 1) * a * a; let err = dx + dy + b1 * a * a; let e2 = 0; if (x1 > x2) { x1 = x2; x2 += a; } if (y1 > y2) { y1 = y2; } y1 += (b + 1) >> 1; y2 = y1 - b1; a = 8 * a * a; b1 = 8 * b * b; do { if (filled) { for (let row = y2; row <= y1; row++) { points.push({ columnIndex: x1, rowIndex: row }); points.push({ columnIndex: x2, rowIndex: row }); } } else { points.push({ columnIndex: x2, rowIndex: y1 }); points.push({ columnIndex: x1, rowIndex: y1 }); points.push({ columnIndex: x1, rowIndex: y2 }); points.push({ columnIndex: x2, rowIndex: y2 }); } e2 = 2 * err; if (e2 <= dy) { y1++; y2--; err += dy += a; } if (e2 >= dx || 2 * err > dy) { x1++; x2--; err += dx += b1; } } while (x1 <= x2); while (y1 - y2 <= b) { points.push({ columnIndex: x1 - 1, rowIndex: y1 }); points.push({ columnIndex: x2 + 1, rowIndex: y1++ }); points.push({ columnIndex: x1 - 1, rowIndex: y2 }); points.push({ columnIndex: x2 + 1, rowIndex: y2-- }); } return points.filter((value, index, self) => index === self.findIndex((t) => (t.columnIndex === value.columnIndex && t.rowIndex === value.rowIndex))); } const getPointFromTouchyEvent = (evt, element, panZoom) => { let originY; let originX; let offsetX; let offsetY; if (window.TouchEvent && evt instanceof TouchEvent) { //this is for tablet or mobile let isCanvasTouchIncluded = false; let firstCanvasTouchIndex = 0; for (let i = 0; i < evt.touches.length; i++) { const target = evt.touches.item(i).target; if (target instanceof HTMLCanvasElement) { isCanvasTouchIncluded = true; firstCanvasTouchIndex = i; break; } } if (isCanvasTouchIncluded) { return getPointFromTouch(evt.touches[firstCanvasTouchIndex], element, panZoom); } else { return getPointFromTouch(evt.touches[0], element, panZoom); } } else { //this is for PC originY = evt.clientY; originX = evt.clientX; offsetX = evt.offsetX; offsetY = evt.offsetY; } originY += window.scrollY; originX += window.scrollX; return { y: originY - panZoom.offset.y, x: originX - panZoom.offset.x, offsetX: offsetX, offsetY: offsetY, }; }; const getPointFromTouch = (touch, element, panZoom) => { const r = element.getBoundingClientRect(); const originY = touch.clientY; const originX = touch.clientX; const offsetX = touch.clientX - r.left; const offsetY = touch.clientY - r.top; return { x: originX - panZoom.offset.x, y: originY - panZoom.offset.y, offsetX: offsetX, offsetY: offsetY, }; }; const calculateNewPanZoomFromPinchZoom = (evt, element, panZoom, zoomSensitivity, prevPinchZoomDiff, minScale, maxScale) => { evt.preventDefault(); if (window.TouchEvent && evt instanceof TouchEvent) { const touchCount = evt.touches.length; if (touchCount < 2) { return; } const canvasTouchEventIndexes = []; for (let i = 0; i < touchCount; i++) { const target = evt.touches.item(i).target; if (target instanceof HTMLCanvasElement) { canvasTouchEventIndexes.push(i); } } if (canvasTouchEventIndexes.length !== 2) { return; } const firstTouch = evt.touches[canvasTouchEventIndexes[0]]; const secondTouch = evt.touches[canvasTouchEventIndexes[1]]; const pinchZoomCurrentDiff = Math.abs(firstTouch.clientX - secondTouch.clientX) + Math.abs(firstTouch.clientY - secondTouch.clientY); const firstTouchPoint = getPointFromTouch(firstTouch, element, panZoom); const secondTouchPoint = getPointFromTouch(secondTouch, element, panZoom); const touchCenterPos = { x: (firstTouchPoint.offsetX + secondTouchPoint.offsetX) / 2, y: (firstTouchPoint.offsetY + secondTouchPoint.offsetY) / 2, }; if (!prevPinchZoomDiff) { return { pinchZoomDiff: pinchZoomCurrentDiff, panZoom }; } const deltaX = prevPinchZoomDiff - pinchZoomCurrentDiff; const zoom = 1 - (deltaX * 2) / (zoomSensitivity * 2); const newScale = panZoom.scale * zoom; if (minScale > newScale || newScale > maxScale) { return; } const worldPos = getWorldPoint(touchCenterPos, { scale: panZoom.scale, offset: panZoom.offset, }); const newTouchCenterPos = getScreenPoint(worldPos, { scale: newScale, offset: panZoom.offset, }); const scaleOffset = diffPoints(touchCenterPos, newTouchCenterPos); const offset = addPoints(panZoom.offset, scaleOffset); return { pinchZoomDiff: pinchZoomCurrentDiff, panZoom: { scale: newScale, offset }, }; } else { return null; } }; const getMouseCartCoord = (evt, element, panZoom, dpr) => { evt.preventDefault(); const point = getPointFromTouchyEvent(evt, element, panZoom); const pointCoord = { x: point.offsetX, y: point.offsetY }; const diffPointsOfMouseOffset = getWorldPoint(pointCoord, panZoom); const mouseCartCoord = diffPoints(diffPointsOfMouseOffset, { x: element.width / dpr / 2, y: element.height / dpr / 2, }); return mouseCartCoord; }; const getCenterCartCoordFromTwoTouches = (evt, element, panZoom, dpr) => { evt.preventDefault(); if (evt.touches && evt.touches.length < 2) return null; if (evt.touches.length > 2) return null; const touch1 = evt.touches[0]; const touch2 = evt.touches[1]; const touch1Point = getPointFromTouch(touch1, element, panZoom); const touch2Point = getPointFromTouch(touch2, element, panZoom); const touchCenterPos = { x: (touch1Point.offsetX + touch2Point.offsetX) / 2, y: (touch1Point.offsetY + touch2Point.offsetY) / 2, }; const diffPointsOfMouseOffset = getWorldPoint(touchCenterPos, panZoom); const mouseCartCoord = diffPoints(diffPointsOfMouseOffset, { x: element.width / dpr / 2, y: element.height / dpr / 2, }); return mouseCartCoord; }; const getPixelIndexFromMouseCartCoord = (mouseCartCoord, sortedRowIndices, sortedColumnIndices, gridSquareLength) => { const leftColumnIndex = sortedColumnIndices[0]; const topRowIndex = sortedRowIndices[0]; const leftTopPoint = { x: leftColumnIndex * gridSquareLength, y: topRowIndex * gridSquareLength, }; if (mouseCartCoord.x > leftTopPoint.x && mouseCartCoord.x < leftTopPoint.x + sortedColumnIndices.length * gridSquareLength && mouseCartCoord.y > leftTopPoint.y && mouseCartCoord.y < leftTopPoint.y + sortedRowIndices.length * gridSquareLength) { // The above conditions are to check if the mouse is in the grid const rowOffset = Math.floor((mouseCartCoord.y - leftTopPoint.y) / gridSquareLength); const columnOffset = Math.floor((mouseCartCoord.x - leftTopPoint.x) / gridSquareLength); return { rowIndex: sortedRowIndices[rowOffset], columnIndex: sortedColumnIndices[columnOffset], }; } return null; }; const getIsPointInsideRegion = (point, area) => { const { areaTopLeftPos, areaBottomRightPos } = getAreaTopLeftAndBottomRight(area); return (point.x >= areaTopLeftPos.x && point.x <= areaBottomRightPos.x && point.y >= areaTopLeftPos.y && point.y <= areaBottomRightPos.y); }; const getDoesAreaOverlapPixelgrid = (area, rowCount, columnCount, gridSquareLength) => { if (!area) return false; const { areaTopLeftPos, areaBottomRightPos } = getAreaTopLeftAndBottomRight(area); const pixelGridLeftTopPoint = { x: -((columnCount / 2) * gridSquareLength), y: -((rowCount / 2) * gridSquareLength), }; const pixelGridRightBottomPoint = { x: (columnCount / 2) * gridSquareLength, y: (rowCount / 2) * gridSquareLength, }; return (areaTopLeftPos.x <= pixelGridRightBottomPoint.x || areaTopLeftPos.y <= pixelGridRightBottomPoint.y || areaBottomRightPos.x >= pixelGridLeftTopPoint.x || areaBottomRightPos.y >= pixelGridLeftTopPoint.y); }; const getAreaTopLeftAndBottomRight = (area) => { const isAreaFromLeftToRight = area.startWorldPos.x < area.endWorldPos.x; const isAreaFromTopToBottom = area.startWorldPos.y < area.endWorldPos.y; // To ease the algorithm, we will first identify the left top, right top, left bottom and right bottom points const areaTopLeftPos = { x: isAreaFromLeftToRight ? area.startWorldPos.x : area.endWorldPos.x, y: isAreaFromTopToBottom ? area.startWorldPos.y : area.endWorldPos.y, }; const areaBottomRightPos = { x: isAreaFromLeftToRight ? area.endWorldPos.x : area.startWorldPos.x, y: isAreaFromTopToBottom ? area.endWorldPos.y : area.startWorldPos.y, }; return { areaTopLeftPos, areaBottomRightPos }; }; const convertWorldPosAreaToPixelGridArea = (selectingArea, rowCount, columnCount, gridSquareLength, rowKeysInOrder, columnKeysInOrder) => { const leftColumnIndex = columnKeysInOrder[0]; const topRowIndex = rowKeysInOrder[0]; const { areaTopLeftPos, areaBottomRightPos } = getAreaTopLeftAndBottomRight(selectingArea); const pixelGridLeftTopPoint = { x: leftColumnIndex * gridSquareLength, y: topRowIndex * gridSquareLength, }; const selectedRegionTopLeft = { x: 0, y: 0, }; const selectedRegionBottomRight = { x: 0, y: 0, }; // leftTopPoint is the the left top point of the grid const selectedAreaLeftOffsetAmount = areaTopLeftPos.x - pixelGridLeftTopPoint.x; // if selectedAreaLeftOffsetAmount is negative, then the selected area's left part is outside the grid if (selectedAreaLeftOffsetAmount < 0) { selectedRegionTopLeft.x = pixelGridLeftTopPoint.x; } else { if (selectedAreaLeftOffsetAmount > columnCount * gridSquareLength) { return null; } selectedRegionTopLeft.x = pixelGridLeftTopPoint.x + Math.floor(selectedAreaLeftOffsetAmount / gridSquareLength) * gridSquareLength; } // if selectedAreaTopOffsetAmount is negative, then the selected area's top part is outside the grid const selectedAreaTopOffsetAmount = areaTopLeftPos.y - pixelGridLeftTopPoint.y; if (selectedAreaTopOffsetAmount < 0) { selectedRegionTopLeft.y = pixelGridLeftTopPoint.y; } else { if (selectedAreaTopOffsetAmount > rowCount * gridSquareLength) { return null; } selectedRegionTopLeft.y = pixelGridLeftTopPoint.y + Math.floor(selectedAreaTopOffsetAmount / gridSquareLength) * gridSquareLength; } // if selectedAreaRightOffsetAmount is positive, then the selected area's right part is outside the grid const selectedAreaRightOffsetAmount = areaBottomRightPos.x - (pixelGridLeftTopPoint.x + columnCount * gridSquareLength); if (selectedAreaRightOffsetAmount > 0) { selectedRegionBottomRight.x = pixelGridLeftTopPoint.x + columnCount * gridSquareLength; } else { if (selectedAreaRightOffsetAmount < -columnCount * gridSquareLength) { return null; } selectedRegionBottomRight.x = pixelGridLeftTopPoint.x + columnCount * gridSquareLength + Math.ceil(selectedAreaRightOffsetAmount / gridSquareLength) * gridSquareLength; } // if selectedAreaBottomOffsetAmount is positive, then the selected area's bottom part is outside the grid const selectedAreaBottomOffsetAmount = areaBottomRightPos.y - (pixelGridLeftTopPoint.y + rowCount * gridSquareLength); if (selectedAreaBottomOffsetAmount > 0) { selectedRegionBottomRight.y = pixelGridLeftTopPoint.y + rowCount * gridSquareLength; } else { if (selectedAreaBottomOffsetAmount < -rowCount * gridSquareLength) { return null; } selectedRegionBottomRight.y = pixelGridLeftTopPoint.y + rowCount * gridSquareLength + Math.ceil(selectedAreaBottomOffsetAmount / gridSquareLength) * gridSquareLength; } const relativeTopLeftRowIndex = Math.floor((selectedRegionTopLeft.y - pixelGridLeftTopPoint.y) / gridSquareLength); const relativeTopLeftColumnIndex = Math.floor((selectedRegionTopLeft.x - pixelGridLeftTopPoint.x) / gridSquareLength); const relativeBottomRightRowIndex = Math.floor((selectedRegionBottomRight.y - pixelGridLeftTopPoint.y) / gridSquareLength); const relativeBottomRightColumnIndex = Math.floor((selectedRegionBottomRight.x - pixelGridLeftTopPoint.x) / gridSquareLength); const includedPixelsIndices = []; for (let i = relativeTopLeftRowIndex; i < relativeBottomRightRowIndex; i++) { for (let j = relativeTopLeftColumnIndex; j < relativeBottomRightColumnIndex; j++) { includedPixelsIndices.push({ rowIndex: rowKeysInOrder[i], columnIndex: columnKeysInOrder[j], }); } } return { startWorldPos: selectedRegionTopLeft, endWorldPos: selectedRegionBottomRight, includedPixelsIndices, }; }; const returnScrollOffsetFromMouseOffset = (mouseOffset, panZoom, newScale) => { const worldPos = getWorldPoint(mouseOffset, panZoom); const newMousePos = getScreenPoint(worldPos, { scale: newScale, offset: panZoom.offset, }); const scaleOffset = diffPoints(mouseOffset, newMousePos); const offset = addPoints(panZoom.offset, scaleOffset); return offset; }; /** * @summary it will return the overlapping pixel indices of the pixels for an extended selected area * @param originalPixels - the original pixels * @param originPixelIndex - the origin pixel index * @param modifyPixelWidthRatio - the ratio of the width of the modified pixel to the original pixel * @param modifyPixelHeightRatio - the ratio of the height of the modified pixel to the original pixel * @param gridSquareLength - the length of the grid square * @returns the pixel indices of the overlapping pixels */ const getOverlappingPixelIndicesForModifiedPixels = (originalPixels, originPixelIndex, modifyPixelWidthRatio, modifyPixelHeightRatio, gridSquareLength) => { if (modifyPixelHeightRatio < 0 || modifyPixelWidthRatio < 0) { throw new Error("modifyPixelHeightRatio and modifyPixelWidthRatio should be positive"); } if (gridSquareLength < 0) { throw new Error("gridSquareLength should be positive"); } const pixelsToColor = []; for (const item of originalPixels) { const pixelDistanceFromOrigin = { rowOffset: item.rowIndex - originPixelIndex.rowIndex, columnOffset: item.columnIndex - originPixelIndex.columnIndex, }; const pixelWordPosOffset = { x: pixelDistanceFromOrigin.columnOffset * gridSquareLength, y: pixelDistanceFromOrigin.rowOffset * gridSquareLength, }; const cornerWorldPos = { topLeft: { x: pixelWordPosOffset.x * modifyPixelWidthRatio, y: pixelWordPosOffset.y * modifyPixelHeightRatio, }, topRight: { x: (pixelWordPosOffset.x + gridSquareLength) * modifyPixelWidthRatio, y: pixelWordPosOffset.y * modifyPixelHeightRatio, }, bottomLeft: { x: pixelWordPosOffset.x * modifyPixelWidthRatio, y: (pixelWordPosOffset.y + gridSquareLength) * modifyPixelHeightRatio, }, bottomRight: { x: (pixelWordPosOffset.x + gridSquareLength) * modifyPixelWidthRatio, y: (pixelWordPosOffset.y + gridSquareLength) * modifyPixelHeightRatio, }, }; for (let i = cornerWorldPos.topLeft.x; i < cornerWorldPos.topRight.x; i += gridSquareLength) { for (let j = cornerWorldPos.topLeft.y; j < cornerWorldPos.bottomLeft.y; j += gridSquareLength) { const pixelIndex = { rowIndex: Math.round(originPixelIndex.rowIndex + Math.floor(j / gridSquareLength)), columnIndex: Math.round(originPixelIndex.columnIndex + Math.floor(i / gridSquareLength)), color: item.color, previousColor: item.previousColor, }; // console.log(originPixelIndex, "originPixelIndex"); // console.log( // pixelIndex, // "pixelIndex", // ); pixelsToColor.push(pixelIndex); } } } return pixelsToColor; }; const getGridIndicesFromData = (data) => { const allRowKeys = Array.from(data.keys()); const allColumnKeys = Array.from(data.get(allRowKeys[0]).keys()); const currentTopIndex = Math.min(...allRowKeys); const currentLeftIndex = Math.min(...allColumnKeys); const currentBottomIndex = Math.max(...allRowKeys); const currentRightIndex = Math.max(...allColumnKeys); return { topRowIndex: currentTopIndex, bottomRowIndex: currentBottomIndex, leftColumnIndex: currentLeftIndex, rightColumnIndex: currentRightIndex, }; }; const getAllGridIndicesSorted = (data) => { const allRowKeys = Array.from(data.keys()); const allColumnKeys = Array.from(data.get(allRowKeys[0]).keys()); return { rowIndices: allRowKeys.sort((a, b) => a - b), columnIndices: allColumnKeys.sort((a, b) => a - b), }; }; /** * @description get all the row keys (sorted) from the data * @param data */ const getRowKeysFromData = (data) => { return Array.from(data.keys()).sort((a, b) => a - b); }; /** * @description get all the column keys (sorted) from the data * @param data */ const getColumnKeysFromData = (data) => { const allRowKeys = getRowKeysFromData(data); const allColumnKeys = Array.from(data.get(allRowKeys[0]).keys()).sort((a, b) => a - b); return allColumnKeys; }; const getColumnCountFromData = (data) => { if (data.size === 0) return 0; return data.entries().next().value[1].size; }; const getRowCountFromData = (data) => { return data.size; }; const extractColoredPixelsFromRow = (data, rowIndex) => { const rowPixelsMap = data.get(rowIndex); const pixelModifyItems = []; Array.from(rowPixelsMap.entries()).forEach(columnData => { const [key, pixel] = columnData; if (pixel.color) { pixelModifyItems.push({ color: pixel.color, rowIndex: rowIndex, columnIndex: key, }); } }); return pixelModifyItems; }; const extractColoredPixelsFromColumn = (data, columnIndex) => { const pixelModifyItems = []; Array.from(data.entries()).map(rowData => { const rowIndex = rowData[0]; const row = rowData[1]; if (row.get(columnIndex).color) { pixelModifyItems.push({ rowIndex: rowIndex, columnIndex: columnIndex, color: row.get(columnIndex).color, }); } }); return pixelModifyItems; }; const deleteRowOfData = (data, rowIndex) => { if (!data.has(rowIndex)) return; data.delete(rowIndex); }; const deleteColumnOfData = (data, columnIndex) => { data.forEach(row => { if (!row.has(columnIndex)) { return; } row.delete(columnIndex); }); }; const addRowToData = (data, rowIndex, defaultColor) => { const columnKeys = getColumnKeysFromData(data); if (data.has(rowIndex)) { return; } data.set(rowIndex, new Map()); for (const i of columnKeys) { data.get(rowIndex).set(i, { color: "" }); } }; const addColumnToData = (data, columnIndex, defaultColor) => { data.forEach(row => { if (!row.has(columnIndex)) { row.set(columnIndex, { color: "" }); } }); }; const validateSquareArray = (data) => { const dataRowCount = data.length; let columnCount = 0; const rowCount = dataRowCount; let isDataValid = true; if (dataRowCount < 2) { isDataValid = false; } else { const dataColumnCount = data[0].length; columnCount = dataColumnCount; if (dataColumnCount < 2) { isDataValid = false; } else { for (let i = 0; i < dataRowCount; i++) { if (data[i].length !== dataColumnCount) { isDataValid = false; break; } } } } return { isDataValid, columnCount, rowCount }; }; const createRowKeyOrderMapfromData = (data) => { const rowKeys = getRowKeysFromData(data); const sortedRowKeys = rowKeys.sort((a, b) => a - b); const minRowKey = sortedRowKeys[0]; const rowKeyOrderMap = new Map(); sortedRowKeys.forEach((key, index) => { rowKeyOrderMap.set(key, index); }); return { rowKeyOrderMap, minRowKey }; }; const createColumnKeyOrderMapfromData = (data) => { const columnKeys = getColumnKeysFromData(data); const sortedColumnKeys = columnKeys.sort((a, b) => a - b); const minColumnKey = sortedColumnKeys[0]; const columnKeyOrderMap = new Map(); sortedColumnKeys.forEach((key, index) => { columnKeyOrderMap.set(key, index); }); return { columnKeyOrderMap, minColumnKey }; }; const getInBetweenPixelIndicesfromCoords = (previousCoord, currentCoord, gridSquareLength, data) => { if (!previousCoord || !currentCoord) return []; if (Math.abs(currentCoord.x - previousCoord.x) >= gridSquareLength || Math.abs(currentCoord.y - previousCoord.y) >= gridSquareLength) { const { rowIndices, columnIndices } = getAllGridIndicesSorted(data); const pixelIndex = getPixelIndexFromMouseCartCoord(currentCoord, rowIndices, columnIndices, gridSquareLength); const previousIndex = getPixelIndexFromMouseCartCoord(previousCoord, rowIndices, columnIndices, gridSquareLength); if (!previousIndex || !pixelIndex) return; if (Math.abs(pixelIndex.columnIndex - previousIndex.columnIndex) >= 1 || Math.abs(pixelIndex.rowIndex - previousIndex.rowIndex) >= 1) { const missingIndices = getBresenhamLineIndices(previousIndex.rowIndex, previousIndex.columnIndex, pixelIndex.rowIndex, pixelIndex.columnIndex); if (missingIndices.length > 0) { return missingIndices; } } } }; const getRectanglePixelIndicesfromCoords = (previousCoord, currentCoord, gridSquareLength, data, filled) => { if (!previousCoord || !currentCoord) return []; if (Math.abs(currentCoord.x - previousCoord.x) >= gridSquareLength || Math.abs(currentCoord.y - previousCoord.y) >= gridSquareLength) { const { rowIndices, columnIndices } = getAllGridIndicesSorted(data); const pixelIndex = getPixelIndexFromMouseCartCoord(currentCoord, rowIndices, columnIndices, gridSquareLength); const previousIndex = getPixelIndexFromMouseCartCoord(previousCoord, rowIndices, columnIndices, gridSquareLength); if (!previousIndex || !pixelIndex) return; const points = []; if (Math.abs(pixelIndex.columnIndex - previousIndex.columnIndex) >= 1 || Math.abs(pixelIndex.rowIndex - previousIndex.rowIndex) >= 1) { const [startRow, endRow] = previousIndex.rowIndex < pixelIndex.rowIndex ? [previousIndex.rowIndex, pixelIndex.rowIndex] : [pixelIndex.rowIndex, previousIndex.rowIndex]; const [startColumn, endColumn] = previousIndex.columnIndex < pixelIndex.columnIndex ? [previousIndex.columnIndex, pixelIndex.columnIndex] : [pixelIndex.columnIndex, previousIndex.columnIndex]; for (let row = startRow; row <= endRow; row++) { for (let column = startColumn; column <= endColumn; column++) { if (filled || row === previousIndex.rowIndex || column === previousIndex.columnIndex || row === pixelIndex.rowIndex || column === pixelIndex.columnIndex) { points.push({ rowIndex: row, columnIndex: column, }); } } } } return points; } }; const getEllipsePixelIndicesfromCoords = (previousCoord, currentCoord, gridSquareLength, data, filled) => { if (!previousCoord || !currentCoord) return []; if (Math.abs(currentCoord.x - previousCoord.x) >= gridSquareLength || Math.abs(currentCoord.y - previousCoord.y) >= gridSquareLength) { const { rowIndices, columnIndices } = getAllGridIndicesSorted(data); const pixelIndex = getPixelIndexFromMouseCartCoord(currentCoord, rowIndices, columnIndices, gridSquareLength); const previousIndex = getPixelIndexFromMouseCartCoord(previousCoord, rowIndices, columnIndices, gridSquareLength); if (!previousIndex || !pixelIndex) return; if (Math.abs(pixelIndex.columnIndex - previousIndex.columnIndex) >= 1 || Math.abs(pixelIndex.rowIndex - previousIndex.rowIndex) >= 1) { const indices = getBresenhamEllipseIndices(previousIndex.columnIndex, previousIndex.rowIndex, pixelIndex.columnIndex, pixelIndex.rowIndex, filled); if (indices.length > 0) { return indices; } } } }; const validateLayers = (layers) => { if (!layers) { throw new Error("No layer provided"); } if (layers.length === 0) { throw new Error("initLayers should not be empty. Please provide at least one layer."); } const layerIdSet = new Set(); let measuredColumnCount = null; let measuredRowCount = null; let measuredTopRowIndex = null; let measuredLeftColumnIndex = null; // all init data passed initially, should be // 1) a square array // 2) all data should have same row and column count // 3) all data should have same topRowIndex and leftColumnIndex layers.forEach(layer => { if (layerIdSet.has(layer.id)) { throw new DuplicateLayerIdError(layer.id); } layerIdSet.add(layer.id); const { isDataValid, columnCount, rowCount } = validateSquareArray(layer.data); if (measuredColumnCount !== null && measuredRowCount !== null) { if (measuredColumnCount !== columnCount || measuredRowCount !== rowCount) { throw new InvalidDataDimensionsError(layer.id); } } else { measuredColumnCount = columnCount; measuredRowCount = rowCount; } if (!isDataValid) { throw new InvalidSquareDataError(layer.id); } if (measuredLeftColumnIndex == null || measuredTopRowIndex == null) { measuredLeftColumnIndex = layer.data[0][0].columnIndex; measuredTopRowIndex = layer.data[0][0].rowIndex; } const topRowIndex = layer.data[0][0].rowIndex; const leftColumnIndex = layer.data[0][0].columnIndex; if (topRowIndex !== measuredTopRowIndex) { throw new InvalidDataIndicesError(layer.id); } if (leftColumnIndex !== measuredLeftColumnIndex) { throw new InvalidDataIndicesError(layer.id); } }); return true; }; class BaseLayer { constructor({ canvas }) { this.panZoom = DefaultPanZoom; // TODO: We needed a key value sorted map! this.rowKeyOrderMap = new Map(); this.columnKeyOrderMap = new Map(); this.topRowIndex = 0; this.leftColumnIndex = 0; this.ctx = canvas.getContext("2d"); this.element = canvas; } getContext() { return this.ctx; } getElement() { return this.element; } getRowKeyOrderMap() { return this.rowKeyOrderMap; } getColumnKeyOrderMap() { return this.columnKeyOrderMap; } setPanZoom(panZoom) { this.panZoom = panZoom; } setCriterionDataForRendering(criterionDataForRendering) { this.criterionDataForRendering = criterionDataForRendering; const { rowKeyOrderMap, minRowKey } = createRowKeyOrderMapfromData(criterionDataForRendering); const { columnKeyOrderMap, minColumnKey } = createColumnKeyOrderMapfromData(criterionDataForRendering); this.rowKeyOrderMap = rowKeyOrderMap; this.columnKeyOrderMap = columnKeyOrderMap; this.topRowIndex = minRowKey; this.leftColumnIndex = minColumnKey; } scale(x, y) { this.ctx.scale(x, y); } setWidth(width, devicePixelRatio) { this.width = width; this.element.width = devicePixelRatio ? width * devicePixelRatio : width; this.element.style.width = `${width}px`; } setHeight(height, devicePixelRatio) { this.height = height; this.element.height = devicePixelRatio ? height * devicePixelRatio : height; this.element.style.height = `${height}px`; } setDpr(dpr) { this.dpr = dpr; } getWidth() { return this.width; } getHeight() { return this.height; } setSize(width, height, devicePixelRatio) { this.setWidth(width, devicePixelRatio); this.setHeight(height, devicePixelRatio); this.dpr = devicePixelRatio ? devicePixelRatio : this.dpr; } setTopRowIndex(topRowIndex) { this.topRowIndex = topRowIndex; } setLeftColumnIndex(leftColumnIndex) { this.leftColumnIndex = leftColumnIndex; } getTopRowIndex() { return this.topRowIndex; } getLeftColumnIndex() { return this.leftColumnIndex; } } class Observable { constructor() { this.observers = []; } subscribe(observer) { this.observers.push(observer); } unsubscribe(observer) { this.observers = this.observers.filter(obs => obs !== observer); } notify(data) { this.observers.forEach(observer => observer.update(data)); } } class DottingDataLayer extends Observable { constructor({ data, id, }) { super(); this.isVisible = true; this.getColumnKeysFromData = () => { const allRowKeys = Array.from(this.data.keys()); const allColumnKeys = Array.from(this.data.get(allRowKeys[0]).keys()); return allColumnKeys; }; this.addRowToData = (rowIndex) => { const columnKeys = this.columnKeys; if (this.data.has(rowIndex)) { return null; } this.data.set(rowIndex, new Map()); this.rowKeys.add(rowIndex); for (const i of columnKeys) { this.data.get(rowIndex).set(i, { color: "" }); } this.notify(this.getData()); return rowIndex; }; this.addColumnToData = (columnIndex) => { let validColumnIndex = null; this.data.forEach(row => { if (!row.has(columnIndex)) { validColumnIndex = columnIndex; row.set(columnIndex, { color: "" }); } }); this.notify(this.getData()); this.columnKeys.add(columnIndex); return validColumnIndex; }; this.getCopiedData = () => { const copiedData = new Map(); this.data.forEach((row, rowIndex) => { copiedData.set(rowIndex, new Map()); row.forEach((column, columnIndex) => { copiedData.get(rowIndex).set(columnIndex, Object.assign({}, column)); }); }); return copiedData; }; const { isDataValid } = validateSquareArray(data); const leftColumnIndex = data[0][0].columnIndex; const topRowIndex = data[0][0].rowIndex; if (!isDataValid) { throw new Error("Data is not valid"); } this.data = new Map(); this.id = id; for (let i = 0; i < data.length; i++) { this.data.set(topRowIndex + i, new Map()); for (let j = 0; j < data[i].length; j++) { this.data .get(topRowIndex + i) .set(leftColumnIndex + j, { color: data[i][j].color }); } } this.rowKeys = new Set(this.data.keys()); this.columnKeys = new Set(this.data.get(topRowIndex).keys()); } getDataInfo() { const gridIndices = getGridIndicesFromData(this.data); const columnCount = getColumnCountFromData(this.data); const rowCount = getRowCountFromData(this.data); return { gridIndices, columnCount, rowCount, }; } getId() { return this.id; } deleteRowOfData(rowIndex) { let validRowIndex = null; if (!this.data.has(rowIndex)) { throw new Error("Row does not exist"); } validRowIndex = rowIndex; this.data.delete(rowIndex); this.rowKeys.delete(rowIndex); this.notify(this.getData()); return validRowIndex; } deleteColumnOfData(columnIndex) { let validColumnIndex = null; this.data.forEach(row => { if (!row.has(columnIndex)) { throw new Error("Column does not exist"); } validColumnIndex = columnIndex; row.delete(columnIndex); }); this.columnKeys.delete(columnIndex); this.notify(this.getData()); return validColumnIndex; } clearData() { const previousPixels = []; const newPixels = []; const rowKeys = Array.from(this.data.keys()); const columnKeys = Array.from(this.data.get(rowKeys[0]).keys()); for (const i of rowKeys) { for (const j of columnKeys) { previousPixels.push({ rowIndex: i, columnIndex: j, color: this.data.get(i).get(j).color, }); newPixels.push({ rowIndex: i, columnIndex: j, color: "", }); this.data.get(i).set(j, { color: "" }); } } this.notify(this.getData()); return { previousPixels, newPixels }; } setData(data) { this.data = data; this.rowKeys = new Set(data.keys()); this.columnKeys = new Set(data.get(Array.from(data.keys())[0]).keys()); this.notify(this.getData()); } setIsVisible(isVisible) { this.isVisible = isVisible; } getData() { return this.data; } getIsVisible() { return this.isVisible; } getDataArray() { const data = []; [...this.data.entries()].forEach(([rowIndex, row]) => { const rowData = []; [...row.entries()].forEach(([columnIndex, column]) => { rowData.push({ rowIndex, columnIndex, color: column.color, }); }); data.push(rowData); }); return data; } } class DataLayer extends BaseLayer { constructor({ canvas, layers, }) { super({ canvas }); this.gridSquareLength = DefaultGridSquareLength; this.defaultPixelColor = DefaultPixelColor; this.capturedImageBitmap = null; this.capturedImageBitmapScale = 1; this.offscreenCanvas = null; // const response = DataLayerWorkerString; // const blob = new Blob([response], { type: "application/javascript" }); // this.setWorker(new Worker(URL.createObjectURL(blob))); if (layers) { const topRowIndex = layers[0].data[0][0].rowIndex; const leftColumnIndex = layers[0].data[0][0].columnIndex; this.setTopRowIndex(topRowIndex); this.setLeftColumnIndex(leftColumnIndex); this.layers = layers.map(layer => new DottingDataLayer({ data: layer.data, id: layer.id, })); } else { const topRowIndex = 0; const leftColumnIndex = 0; this.setTopRowIndex(topRowIndex); this.setLeftColumnIndex(leftColumnIndex); const defaultNestedArray = []; const { rowCount, columnCount } = DefaultPixelDataDimensions; this.offscreenCanvas = new OffscreenCanvas(columnCount * this.gridSquareLength, rowCount * this.gridSquareLength); for (let i = 0; i < rowCount; i++) { defaultNestedArray.push([]); for (let j = 0; j < columnCount; j++) { defaultNestedArray[i].push({ color: "", rowIndex: i, columnIndex: j, }); } } this.layers = [ new DottingDataLayer({ data: defaultNestedArray, id: "layer1", }), ]; } this.currentLayer = this.layers[0]; } getColumnCount() { return getColumnCountFromData(this.getData()); } getRowCount() { return getRowCountFromData(this.getData()); } getGridIndices() { return getGridIndicesFromData(this.getData()); } getDimensions() { return { columnCount: this.getColumnCount(), rowCount: this.getRowCount(), }; } getData() { return this.currentLayer.getData(); } getLayer(layerId) { const layer = this.layers.find(layer => layer.getId() === layerId); if (layer) { return layer; } else { return null; } } getLayerIndex(layerId) { return this.layers.findIndex(layer => layer.getId() === layerId); } getLayers() { return this.layers; } createLayer(layerId, data) { const layer = this.getLayer(layerId); if (layer) { throw new Error("Layer already exists"); } else { const currentLayerInfo = this.currentLayer.getDataInfo(); if (data) { const { isDataValid, rowCount, columnCount } = validateSquareArray(data); const leftColumnIndex = data[0][0].columnIndex; const topRowIndex = data[0][0].rowIndex; if (!isDataValid) { throw new Error("Data is not square"); } if (leftColumnIndex !== currentLayerInfo.gridIndices.leftColumnIndex || topRowIndex !== currentLayerInfo.gridIndices.topRowIndex) { throw new Error("Data grid indice differs from current layer"); } if (rowCount !== currentLayerInfo.rowCount || columnCount !== currentLayerInfo.columnCount) { throw new Error("Data dimensions differs from current layer"); } return new DottingDataLayer({ data, id: layerId, }); } else { const emptyArray = []; for (let i = 0; i < currentLayerInfo.rowCount; i++) { emptyArray.push([]); for (let j = 0; j < currentLayerInfo.columnCount; j++) { emptyArray[i].push({ rowIndex: currentLayerInfo.gridIndices.topRowIndex + i, columnIndex: currentLayerInfo.gridIndices.leftColumnIndex + j, color: "", }); } } return new DottingDataLayer({ data: emptyArray, id: layerId, }); } } } getCopiedData() { const copiedMap = new Map(); Array.from(this.getData().entries()).forEach(([rowIndex, row]) => { const copiedRow = new Map(); Array.from(row.entries()).forEach(([columnIndex, pixelData]) => { copiedRow.set(columnIndex, Object.assign({}, pixelData)); }); copiedMap.set(rowIndex, copiedRow); }); return copiedMap; } getCurrentLayer() { return this.currentLayer; } hideLayer(layerId) { const layer = this.getLayer(layerId); if (!layer) { throw new Error("Layer not found"); } layer.setIsVisible(false); } showLayer(layerId) { const layer = this.getLayer(layerId); if (!layer) { throw new Error("Layer not found"); } layer.setIsVisible(true); } isolateLayer(layerId) {