diff --git a/.gitignore b/.gitignore index c39c435..404d351 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ *.d.ts -*.css lib node_modules -lib diff --git a/src/components/TileMap/TileMap.tsx b/src/components/TileMap/TileMap.tsx index 04ac68e..2540554 100644 --- a/src/components/TileMap/TileMap.tsx +++ b/src/components/TileMap/TileMap.tsx @@ -38,11 +38,12 @@ export class TileMap extends React.PureComponent { padding: 4, isDraggable: true, layers: [], - renderMap: renderMap + renderMap: renderMap, } private oldState: State private canvas: HTMLCanvasElement | null + private strikeCanvas: HTMLCanvasElement private mounted: boolean private hover: Coord | null private popupTimeout: number | null @@ -61,17 +62,18 @@ export class TileMap extends React.PureComponent { pan: { x: panX, y: panY }, center: { x: x == null ? initialX : x, - y: y == null ? initialY : y + y: y == null ? initialY : y, }, size: zoom * size, zoom, - popup: null + popup: null, } this.state = this.generateState(props, initialState) this.oldState = this.state this.hover = null this.mounted = false this.canvas = null + this.strikeCanvas = document.createElement('canvas') this.popupTimeout = null } @@ -86,12 +88,12 @@ export class TileMap extends React.PureComponent { ...nextState, center: { x: nextProps.x, - y: nextProps.y + y: nextProps.y, }, pan: { x: 0, - y: 0 - } + y: 0, + }, } } @@ -122,7 +124,7 @@ export class TileMap extends React.PureComponent { if (newZoom !== this.props.zoom && newZoom !== this.state.zoom) { this.setState({ zoom: newZoom, - size: this.props.size * newZoom + size: this.props.size * newZoom, }) } } @@ -174,7 +176,7 @@ export class TileMap extends React.PureComponent { center, pan, size, - padding + padding, }) return { ...viewport, pan, zoom, center, size } } @@ -208,18 +210,18 @@ export class TileMap extends React.PureComponent { const boundaries = { nw: { x: minX - halfWidth, y: maxY + halfHeight }, - se: { x: maxX + halfWidth, y: minY - halfHeight } + se: { x: maxX + halfWidth, y: minY - halfHeight }, } const viewport = { nw: { x: this.state.center.x - halfWidth, - y: this.state.center.y + halfHeight + y: this.state.center.y + halfHeight, }, se: { x: this.state.center.x + halfWidth, - y: this.state.center.y - halfHeight - } + y: this.state.center.y - halfHeight, + }, } if (viewport.nw.x + newPan.x / newSize < boundaries.nw.x) { @@ -238,7 +240,7 @@ export class TileMap extends React.PureComponent { this.setState({ pan: newPan, zoom: newZoom, - size: newSize + size: newSize, }) this.renderMap() this.debouncedUpdateCenter() @@ -252,7 +254,7 @@ export class TileMap extends React.PureComponent { const viewportOffset = { x: (width - padding - 0.5) / 2 - center.x, - y: (height - padding) / 2 + center.y + y: (height - padding) / 2 + center.y, } const coordX = Math.round(panOffset.x - viewportOffset.x) @@ -326,7 +328,7 @@ export class TileMap extends React.PureComponent { if (this.mounted) { this.setState( { - popup: { x, y, top, left, visible: true } + popup: { x, y, top, left, visible: true }, }, () => onPopup(this.state.popup!) ) @@ -352,8 +354,8 @@ export class TileMap extends React.PureComponent { { popup: { ...this.state.popup, - visible: false - } + visible: false, + }, }, () => { onPopup(this.state.popup!) @@ -371,26 +373,31 @@ export class TileMap extends React.PureComponent { const newPan = { x: panX, y: panY } const newCenter = { x: center.x + Math.floor((pan.x - panX) / size), - y: center.y - Math.floor((pan.y - panY) / size) + y: center.y - Math.floor((pan.y - panY) / size), } this.setState({ pan: newPan, - center: newCenter + center: newCenter, }) } renderMap() { - if (!this.canvas) { + const strikeContext = this.strikeCanvas.getContext('2d') + + if (!this.canvas || !strikeContext) { return } const { width, height, layers, renderMap } = this.props - const { nw, se, pan, size, center } = this.state + const { nw, se, pan, size, center, zoom } = this.state + this.strikeCanvas.width = width + this.strikeCanvas.height = height const ctx = this.canvas.getContext('2d')! renderMap({ ctx, + strikeCanvasCtx: strikeContext, width, height, size, @@ -398,7 +405,8 @@ export class TileMap extends React.PureComponent { nw, se, center, - layers + layers, + zoom, }) } diff --git a/src/components/TileMap/TileMap.types.ts b/src/components/TileMap/TileMap.types.ts index 6a12248..9a65a47 100644 --- a/src/components/TileMap/TileMap.types.ts +++ b/src/components/TileMap/TileMap.types.ts @@ -74,6 +74,7 @@ export type Popup = { export type MapRenderer = (args: { ctx: CanvasRenderingContext2D + strikeCanvasCtx: CanvasRenderingContext2D width: number height: number size: number @@ -82,4 +83,5 @@ export type MapRenderer = (args: { se: Coord center: Coord layers: Layer[] + zoom: number }) => void diff --git a/src/lib/common.ts b/src/lib/common.ts index 70085c8..9055301 100644 --- a/src/lib/common.ts +++ b/src/lib/common.ts @@ -11,4 +11,5 @@ export type Tile = { left?: boolean topLeft?: boolean scale?: number + strikethrough?: boolean } diff --git a/src/render/map.ts b/src/render/map.ts index 4175128..8b098fa 100644 --- a/src/render/map.ts +++ b/src/render/map.ts @@ -1,8 +1,15 @@ -import { renderTile } from './tile' +import { + addClippingContextForTileStrike, + buildStrikethroughPattern, + finishClippingForTileStrike, + initializeContextForTileStrike, + renderTile, +} from './tile' import { Coord, Layer } from '../lib/common' export function renderMap(args: { ctx: CanvasRenderingContext2D + strikeCanvasCtx: CanvasRenderingContext2D width: number height: number size: number @@ -11,15 +18,23 @@ export function renderMap(args: { se: Coord center: Coord layers: Layer[] + zoom: number }) { - const { ctx, width, height, size, pan, nw, se, center, layers } = args - + const { ctx, strikeCanvasCtx, width, height, size, pan, nw, se, center, layers } = args + // Creates the strikethrough pattern that will be used to draw the strikethrough in the tiles. + // The scale is set to 1 as scaling the pattern makes it very difficult to be used into a repeatable pattern. + const strikethroughPattern = buildStrikethroughPattern(ctx, 1) + // Sets up the clipping region to be used in the strike process. + let clippingRegion: Path2D ctx.clearRect(0, 0, width, height) const halfWidth = width / 2 const halfHeight = height / 2 for (const layer of layers) { + // Initializes the strike canvas and its context + clippingRegion = initializeContextForTileStrike(strikeCanvasCtx, width, height) + let hasStrikeTile = false for (let x = nw.x; x < se.x; x++) { for (let y = se.y; y < nw.y; y++) { const offsetX = (center.x - x) * size + (pan ? pan.x : 0) @@ -29,7 +44,7 @@ export function renderMap(args: { if (!tile) { continue } - const { color, top, left, topLeft, scale } = tile + const { color, top, left, topLeft, scale, strikethrough } = tile const halfSize = scale ? (size * scale) / 2 : size / 2 @@ -44,9 +59,45 @@ export function renderMap(args: { left, top, topLeft, - scale + scale, }) + + if (strikethrough) { + hasStrikeTile = true + // Adds the rectangles figures to the clipping region to later clip everything outside of those regions + addClippingContextForTileStrike({ + ctx: strikeCanvasCtx, + region: clippingRegion, + x: halfWidth - offsetX + halfSize, + y: halfHeight - offsetY + halfSize, + size, + padding: size < 7 ? 0.5 : size < 12 ? 1 : size < 18 ? 1.5 : 2, + offset: 1, + left, + top, + topLeft, + scale, + }) + } } } + if (strikethroughPattern && hasStrikeTile) { + // Applies the pattern and clips the regions to produce the strike pattern over the tiles. + finishClippingForTileStrike( + strikeCanvasCtx, + clippingRegion, + strikethroughPattern, + width, + height + ) + } + // Draw the strike pattern + if ( + strikeCanvasCtx.canvas.width > 0 && + strikeCanvasCtx.canvas.height > 0 && + hasStrikeTile + ) { + ctx.drawImage(strikeCanvasCtx.canvas, 0, 0) + } } } diff --git a/src/render/tile.ts b/src/render/tile.ts index e845917..5e9e59f 100644 --- a/src/render/tile.ts +++ b/src/render/tile.ts @@ -10,6 +10,8 @@ export function renderTile(args: { top?: boolean topLeft?: boolean scale?: number + strikethrough?: boolean + strikethroughPattern?: CanvasPattern }) { const { ctx, x, y, size, padding, offset, color, left, top, topLeft, scale } = args @@ -54,3 +56,137 @@ export function renderTile(args: { } } } + +/** + * Creates a strikethrough pattern that can be later used to draw over a canvas. + * @param ctx The context of the + * @param scale The scaling factor for the lines thickness. + * @returns a CanvasPattern with the strikethrough pattern or null if the browser didn't provide a context. + */ +export function buildStrikethroughPattern( + ctx: CanvasRenderingContext2D, + scale?: number +): CanvasPattern | null { + const patternCanvas = document.createElement('canvas') + const patternContext = patternCanvas.getContext('2d') + if (!patternContext) { + return null + } + + patternCanvas.width = 200 + patternCanvas.height = 200 + + const blackColor = '#000000' + // Uses a fixed number of stripes with a thickness based on the original size of the tile. + const thickness = 10 * (scale ?? 1) + const numberOfStripes = patternCanvas.width / thickness + patternContext.globalAlpha = 0.3 + + patternContext.beginPath() + for (var i = 0; i < numberOfStripes * 2; i += 2) { + patternContext.strokeStyle = blackColor + patternContext.lineWidth = thickness / 1.5 + patternContext.lineCap = 'square' + patternContext.moveTo(i * thickness + thickness / 2 - patternCanvas.width, 0) + patternContext.lineTo(i * thickness + thickness / 2, patternCanvas.width) + } + patternContext.stroke() + + return ctx.createPattern(patternCanvas, 'repeat') +} + +/** + * Initializes the process of creating the strikethrough clipping canvas. + * This function clears the existing canvas, starts a new path where to write the clipping rectangles + * that will be used to draw the tiles and creates the path for the clipping rectangles. + * @param ctx The canvas context where to draw the strikethrough screen. + * @param width The width of the canvas. + * @param height The height of the canvas. + * @returns a new Path2D where all the clipping rectangles will be added to. + */ +export function initializeContextForTileStrike( + ctx: CanvasRenderingContext2D, + width: number, + height: number +): Path2D { + ctx.clearRect(0, 0, width, height) + ctx.beginPath() + return new Path2D() +} + +/** + * Draws a clipping rectangle and adds it to the clipping region to be later clipped. + * @param args The set of arguments that define how to draw a clipping rectangle. + */ +export function addClippingContextForTileStrike(args: { + ctx: CanvasRenderingContext2D + region: Path2D + x: number + y: number + size: number + padding: number + offset: number + left?: boolean + top?: boolean + topLeft?: boolean + scale?: number +}): void { + const { x, y, size, padding, offset, left, top, topLeft, scale, region } = args + const tileSize = scale ? size * scale : size + + if (!top && !left) { + region.rect( + x - tileSize + padding, + y - tileSize + padding, + tileSize - padding, + tileSize - padding + ) + } else if (top && left && topLeft) { + region.rect( + x - tileSize - offset, + y - tileSize - offset, + tileSize + offset, + tileSize + offset + ) + } else { + if (left) { + region.rect( + x - tileSize - offset, + y - tileSize + padding, + tileSize + offset, + tileSize - padding + ) + } + if (top) { + region.rect( + x - tileSize + padding, + y - tileSize - offset, + tileSize - padding, + tileSize + offset + ) + } + } +} + +/** + * Fills the canvas with a pattern and clips the figures defined in the region to produce a canvas that + * contains figures with a strikethrough pattern. + * @param ctx The context of the canvas where to draw the figures. + * @param region The region containing the figures to be clipped out of the pattern. + * @param pattern The pattern which will server as the figures background after clipping them. + * @param width The width of the canvas to write the strike pattern onto. + * @param height The height of the canvas to write the strike pattern onto. + */ +export function finishClippingForTileStrike( + ctx: CanvasRenderingContext2D, + region: Path2D, + pattern: CanvasPattern, + width: number, + height: number +) { + ctx.save() + ctx.clip(region) + ctx.fillStyle = pattern + ctx.fillRect(0, 0, width, height) + ctx.restore() +} diff --git a/stories/10_Atlas_strikethrough_tiles.stories.tsx b/stories/10_Atlas_strikethrough_tiles.stories.tsx new file mode 100644 index 0000000..2a9cf3e --- /dev/null +++ b/stories/10_Atlas_strikethrough_tiles.stories.tsx @@ -0,0 +1,130 @@ +import * as React from 'react' + +import { storiesOf } from '@storybook/react' +// @ts-ignore +import { withKnobs } from '@storybook/addon-knobs' + +import { TileMap, Layer } from '../src' +import { Coord, Tile } from '../src/lib/common' + +import './stories.css' + +let selected: Coord[] = [] + +const isSelected = (x: number, y: number) => + selected.some((coord) => coord.x === x && coord.y === y) + +const handleClick = (x: number, y: number) => { + if (isSelected(x, y)) { + selected = selected.filter((coord) => !(coord.x === x && coord.y === y)) + } else { + selected.push({ x, y }) + } +} + +const selectedStrokeLayer: Layer = (x, y) => { + return isSelected(x, y) ? { color: '#ff0044', scale: 1.4 } : null +} + +const selectedFillLayer: Layer = (x, y) => { + const tile = layer.get(x)?.get(y) ? layer.get(x)!.get(y)! : undefined + + return isSelected(x, y) ? { color: '#ff9990', scale: 1.2, ...tile } : null +} + +const layer = new Map>() +for (let i = -5; i < 6; i++) { + layer.set(i, new Map()) +} + +// Single tile +layer.get(0)?.set(0, { + color: '#ff9990', + strikethrough: true, +}) + +// Vertical rectangle +for (let i = 2; i < 5; i++) { + layer.get(0)?.set(i, { + color: '#ff9990', + top: i !== 4, + strikethrough: true, + }) +} + +// Horizontal rectangle +for (let i = 2; i < 5; i++) { + layer.get(i)?.set(0, { + color: '#ff9990', + left: i !== 2, + strikethrough: true, + }) +} + +// Square +for (let i = 2; i < 4; i++) { + for (let j = 2; j < 4; j++) { + layer.get(i)?.set(j, { + color: '#ff9990', + left: i === 3, + top: j === 2, + topLeft: i === 3 && j === 2, + strikethrough: true, + }) + } +} + +// Long rectangle +for (let i = -5; i < 0; i++) { + layer.get(i)?.set(-3, { + color: '#ff9990', + left: i !== -5, + strikethrough: true, + }) + layer.get(i)?.set(-4, { + color: '#ff9990', + left: i !== -5, + top: true, + topLeft: i !== -5, + strikethrough: true, + }) +} + +// Tetris brick +layer.get(2)?.set(-2, { + color: '#ff9990', + strikethrough: true, +}) +layer.get(2)?.set(-3, { + color: '#ff9990', + top: true, + strikethrough: true, +}) +layer.get(3)?.set(-3, { + color: '#ff9990', + topLeft: true, + left: true, + strikethrough: true, +}) +layer.get(3)?.set(-4, { + color: '#ff9990', + top: true, + strikethrough: true, +}) + +const chessboardLayer: Layer = (x, y): Tile => { + return !layer.get(x)?.get(y) ? { color: '#888888' } : layer.get(x)!.get(y)! +} + +const stories = storiesOf('TileMap', module) + +stories.addDecorator(withKnobs) + +stories.add('10. Strikethrough layer', () => { + return ( + + ) +})