<div class="demo-container">
<div id="demo"></div>
</div>
// global rules
* {
box-sizing: border-box;
position: relative;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100vh;
background: #fff;
color: #333;
line-height: 1.5;
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
}
input, button {
border: none;
outline: none;
background: none;
font-family: inherit;
}
button {
cursor: pointer;
}
.animated-border {
// allows you to override it with higher level variable
--bw: var(--border-width, 3px);
z-index: 2;
overflow: hidden;
position: absolute;
inset: 0;
clip-path: polygon(
0 0, 100% 0, 100% 100%, 0 100%, 0 0, // first lap
var(--bw) var(--bw), // init position for second lap
var(--bw) calc(100% - var(--bw)), // go down
calc(100% - var(--bw)) calc(100% - var(--bw)), // to the right
calc(100% - var(--bw)) var(--bw), // up
var(--bw) var(--bw) // finish lap
);
@keyframes rotateBtnBg {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
&:before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 150%;
padding-bottom: 150%;
transform: translate(-50%, -50%);
background: conic-gradient(#ff4800, #dfd902, #20dc68, #0092f4, #da54d8);
animation: rotateBtnBg 2s linear infinite;
}
&.alt-2:before {
background: conic-gradient(#f56127, #a6ff27, #1de1f5, #2e3bff, #ff225d);
}
}
// DEMO STYLES
.chess {
$parentRef: &;
// default values, overridden by react component
--box-size: 60px;
--x-size: 6;
--y-size: 6;
--knight-x: 3;
--knight-y: 3;
--step-at: 0.2s;
display: flex;
flex-shrink: 0;
width: calc(var(--box-size) * var(--x-size));
height: calc(var(--box-size) * var(--y-size));
@mixin isMoving {
#{$parentRef}.s--moving & {
@content;
}
}
@keyframes fadeInKnight {
to {
opacity: 0.5;
}
}
&__knight {
position: absolute;
left: 0;
top: 0;
width: var(--box-size);
height: var(--box-size);
will-change: transform, opacity;
transition: transform var(--step-at), opacity 0.3s;
transform: translate(
calc(var(--knight-x) * var(--box-size)),
calc(var(--knight-y) * var(--box-size))
);
opacity: 0;
animation: fadeInKnight 1s 0.5s forwards;
@include isMoving {
opacity: 1;
}
}
&__box {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: var(--box-size);
height: var(--box-size);
// border: 1px solid #333;
font-size: 20px;
@keyframes fadeIn {
to {
opacity: 1;
}
}
p {
opacity: 0;
will-change: opacity;
animation: fadeIn var(--step-at) calc(var(--step-at) * calc(var(--move-index) + 1)) forwards;
}
}
@keyframes animateBorder {
to {
transform: scale(1, 1);
}
}
&__line {
position: absolute;
background: #333;
will-change: transform;
animation: animateBorder 1s calc(0.2s + var(--delay, 0) * 0.2s) forwards;
&--x {
left: 0;
top: calc(var(--y, 0) * var(--box-size));
width: 100%;
height: 1px;
transform: scale(0, 1);
}
&--y {
left: calc(var(--x, 0) * var(--box-size));
top: 0;
width: 1px;
height: 100%;
transform: scale(1, 0);
}
}
}
// DEMO CONTAINER, NOT DEMO RELATED
.demo-container {
--vert-gap: 0px;
overflow: hidden;
width: 1600px;
max-width: 100%;
margin: var(--vert-gap) auto;
}
.demo-content {
overflow-y: auto;
overflow-x: auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 20px;
width: 100%;
height: calc(100vh - var(--header-height, 0px) - calc(var(--vert-gap) * 2));
min-height: 640px;
padding: 20px;
background: var(--color-gray);
border: 1px solid #ccc;
@media (max-width: 480px) {
min-height: 800px;
}
}
.demo-error {
text-align: center;
button {
margin-top: 10px;
padding: 10px 16px;
font-size: 20px;
}
}
.demo-params {
z-index: 1000;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
will-change: transform;
transition: transform 0.3s;
transform: translateY(100%);
&:before {
content: '';
position: absolute;
inset: 0;
box-shadow: 2px -3px 3px rgba(0,0,0,0.1);
opacity: 0;
transition: opacity 0.3s;
will-change: opacity;
}
&.s--visible {
transform: translateY(0);
&:before {
opacity: 1;
}
}
&__inner {
overflow-y: auto;
overflow-x: hidden;
height: 100%;
max-height: 200px;
padding: 20px;
border: 2px solid rgba(0,0,0,0.2);
background: #fff;
}
&__content {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
width: 1000px;
max-width: 100%;
margin: 0 auto;
}
&__inputs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
label {
p {
margin-bottom: 4px;
font-size: 14px;
}
}
input {
width: 100%;
padding: 10px 10px;
border: 1px solid #ccc;
font-size: 16px;
}
}
&__submit {
flex-shrink: 0;
padding: 10px 16px;
font-size: 20px;
cursor: pointer;
}
&__toggle {
position: absolute;
bottom: 100%;
right: 30px;
padding: 8px 16px;
font-size: 20px;
background: #fff;
cursor: pointer;
}
}
.demo-callout {
z-index: 100;
position: absolute;
left: 0;
bottom: 20px;
width: 100%;
text-align: center;
}
View Compiled
import React, { useState, useEffect, useRef } from 'https://esm.sh/react@18.2.0'
import ReactDOM from 'https://esm.sh/react-dom@18.2.0'
import cn from "https://cdn.skypack.dev/classnames@2.3.2";
import { ErrorBoundary } from "https://cdn.skypack.dev/react-error-boundary";
// helper functions
const rangeFromZero = (numOfItems: number): number[] => {
return Array.from(Array(numOfItems).keys());
};
// SOLVER CODE
type PossibleMoves = number[][];
// gets all possible moves for a given cell while considering the board constraints
function getPossibleMoves(
h: number,
v: number,
x: number,
y: number,
): PossibleMoves {
const result = [];
const left = x - 2;
const right = x + 2;
const top = y - 2;
const bottom = y + 2;
if (left >= 0) {
if (y - 1 >= 0) {
result.push([left, y - 1]);
}
if (y + 1 <= v - 1) {
result.push([left, y + 1]);
}
}
if (right <= h - 1) {
if (y - 1 >= 0) {
result.push([right, y - 1]);
}
if (y + 1 <= v - 1) {
result.push([right, y + 1]);
}
}
if (top >= 0) {
if (x - 1 >= 0) {
result.push([x - 1, top]);
}
if (x + 1 <= h - 1) {
result.push([x + 1, top]);
}
}
if (bottom <= v - 1) {
if (x - 1 >= 0) {
result.push([x - 1, bottom]);
}
if (x + 1 <= h - 1) {
result.push([x + 1, bottom]);
}
}
return result;
};
/* given possible moves for a particular cell, it filters out moves that are already taken
and sorts in ascending order the remaining moves by the number of possible moves each target cell has
this allows us to minimize the amount of possible "ophan cells" when we are running the solver
without it solver pretty much dies on 8x8 board most of the time, with it we can do 10x10 and even more sometimes
*/
function getFilteredMoves(
moves: PossibleMoves,
takenMovesPositions: string[],
cellsMoves: Record<string, PossibleMoves>,
) {
return moves.filter((m) => {
return !takenMovesPositions.includes(stringifyPosition(m[0], m[1]));
}).sort((a, b) => {
return cellsMoves[stringifyPosition(a[0], a[1])]?.length - cellsMoves[stringifyPosition(b[0], b[1])]?.length;
});
};
// creates mapping of all possible moves for each cell
const getCellsMoves = (h: number, v: number) => {
return rangeFromZero(h * v).reduce((acc, cellIndex) => {
const cellX = cellIndex % h;
const cellY = Math.floor(cellIndex / h);
const position = stringifyPosition(cellX, cellY);
acc[position] = getPossibleMoves(h, v, cellX, cellY);
return acc;
}, {} as Record<string, PossibleMoves>);
};
// this simplifies usage of object mapping, where every cell can be represented as x-y string
export function stringifyPosition(x: number, y: number) {
return `${x}-${y}`;
}
export type Solution = string[];
export function solveMoves(
h: number, // horizontal size of the board
v: number, // vertical size
initialX: number, // initial X position of the knight
initialY: number, // initial Y position
): Solution {
const t1 = performance.now();
const cellsMoves = getCellsMoves(h, v);
const takenMoves = [stringifyPosition(initialX, initialY)];
const pastAlternativeMoves: PossibleMoves[] = [];
const numOfCells = h * v;
let move = [initialX, initialY];
while (takenMoves.length < numOfCells) {
const position = stringifyPosition(move[0], move[1]);
const possibleMoves = getFilteredMoves(cellsMoves[position], takenMoves, cellsMoves);
move = possibleMoves[0];
let restMoves = possibleMoves.slice(1);
let lastAlternativeMoves;
/* if there are no possible moves for the current cell,
we are removing the last item from pastAlternativeMoves and assigning it to lastAlternativeMoves
*/
while (!move && (lastAlternativeMoves = pastAlternativeMoves.pop())) {
takenMoves.pop(); // removing last taken move from our solution, since it was a dead end
// if lastAlternativeMoves has possible moves, then we are continuing our top-level while loop as usual
// but if it doesn't, this while loop with pop repeats again and again until we can find an alternative cell in history with possible moves
if (lastAlternativeMoves?.length) {
// wrapping it in parentheses allows us to assign it to let variables with destructuring, without needing to create new intermediate let variables
([move, ...restMoves] = lastAlternativeMoves);
}
}
// if we arrive to this point and "move" is undefined, it means that we have no more possible moves
if (!move) {
console.log('no solution');
return rangeFromZero(numOfCells).map(() => {
return stringifyPosition(-1, -1); // placeholder values for no solution
});
}
takenMoves.push(stringifyPosition(move[0], move[1]));
pastAlternativeMoves.push(restMoves);
}
console.log('Chess Knight Moves solver time taken', performance.now() - t1);
return takenMoves;
};
// REACT DEMO CODE
export interface Props {
boxSize?: number;
xSize?: number;
ySize?: number;
startX?: number;
startY?: number;
stepAnimationTime?: number;
}
function ChessKnightMovesDemo({
boxSize = 60,
xSize = 6,
ySize = 6,
startX = 3,
startY = 3,
stepAnimationTime = 0.2,
}: Props) {
const [isMoving, setIsMoving] = useState(false);
const [moves, setMoves] = useState<Solution>([]);
const [knightX, setKnightX] = useState(startX - 1);
const [knightY, setKnightY] = useState(startY - 1);
const startDelayRef = useRef<NodeJS.Timeout>();
const intervalRef = useRef<NodeJS.Timeout>();
const moveKnight = (solution: Solution) => {
let index = 1; // initial knight position is 0, so in animation we need to start at 1
// initial interval that changes knight position every stepAnimationTime seconds until it reaches the end
intervalRef.current = setInterval(() => {
const [x, y] = solution[index].split('-').map(Number);
setKnightX(x);
setKnightY(y);
index += 1;
if (index >= solution.length) {
clearInterval(intervalRef.current);
setIsMoving(false);
}
}, stepAnimationTime * 1000);
};
// run once on mount, which also reruns on component key change reset
useEffect(() => {
const solution = solveMoves(xSize, ySize, knightX, knightY);
startDelayRef.current = setTimeout(() => {
setIsMoving(true);
setMoves(solution);
moveKnight(solution);
}, 1000);
// clear timeouts/intervals on component unmount in case it's still running. Also clear on key change reset
return () => {
if (startDelayRef.current) {
clearTimeout(startDelayRef.current);
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const styleObj = {
'--box-size': `${boxSize}px`,
'--x-size': xSize,
'--y-size': ySize,
'--knight-x': knightX,
'--knight-y': knightY,
'--step-at': `${stepAnimationTime}s`,
} as React.CSSProperties;
return (
<div className={cn('chess', { 's--moving': isMoving })} style={styleObj}>
<img
src="https://assets.codepen.io/142996/chess-knight.svg"
alt="Chess Knight"
className="chess__knight"
/>
{/* render borderless chess cells */}
{rangeFromZero(xSize * ySize).map((i) => {
const x = i % xSize;
const y = Math.floor(i / xSize);
const position = stringifyPosition(x, y);
const moveIndex = moves.findIndex((move) => position === move);
return (
<div
key={position}
className="chess__box"
style={{
left: x * boxSize,
top: y * boxSize,
}}
>
{moveIndex !== -1 && (
<p style={{ '--move-index': moveIndex } as React.CSSProperties}>
{moveIndex}
</p>
)}
</div>
);
})}
{/* render vertical board borders */}
{rangeFromZero(xSize + 1).map((i) => (
<div
key={i}
className="chess__line chess__line--y"
style={
{
'--x': i,
'--delay': getBorderDelayIndex(i, xSize),
} as React.CSSProperties
}
/>
))}
{/* render horizontal board borders */}
{rangeFromZero(ySize + 1).map((i) => (
<div
key={i}
className="chess__line chess__line--x"
style={
{
'--y': i,
'--delay': getBorderDelayIndex(i, ySize),
} as React.CSSProperties
}
/>
))}
</div>
);
}
// borders in the middle getting lowest delay, borders on the edges getting highest delay
function getBorderDelayIndex(index: number, size: number) {
return Math.abs(Math.round(size) / 2 - index);
}
// DEMO CONTAINER CODE, NOT DEMO RELATED
const preventNonNumbers = (allowDecimals = false) => (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
return;
}
const { value } = e.currentTarget;
const regex = (allowDecimals && value?.length && !value.includes('.'))
? /[0-9]|\./
: /[0-9]/;
if (!regex.test(e.key)) {
e.preventDefault();
}
}
type PropsType = Record<string, number>;
type ParamsConfigType = Record<
string,
{
label: string;
min?: number | string;
max?: number | string;
allowDecimals?: boolean;
decimalStep?: number;
}
>;
function DemoContainer({
component: Component,
beforeDemo,
afterDemo,
initialProps = {},
paramsConfig = {},
paramsAutoopenDelay = 0,
paramsInitialVisible = false,
withCallout = true,
calloutStyle,
style,
}: {
component: React.ComponentType<PropsType>;
beforeDemo?: React.ReactNode;
afterDemo?: React.ReactNode;
initialProps?: PropsType;
paramsConfig?: ParamsConfigType;
paramsAutoopenDelay?: number;
paramsInitialVisible?: boolean;
withCallout?: boolean;
calloutStyle?: React.CSSProperties;
style?: React.CSSProperties;
}) {
const [props, setProps] = useState(initialProps);
const [resetKey, setResetKey] = useState(0);
const [paramsResetKey, setParamsResetKey] = useState(0);
const handleChangeProps = (newProps: PropsType) => {
setProps(newProps);
setResetKey(Date.now());
};
return (
<div className="demo-container">
<div className="demo-content" style={style}>
<ErrorBoundary
key={resetKey}
fallback={
<div className="demo-error">
<p>
Oops, seems that demo couldn't handle provided params and
crashed!
</p>
<button
onClick={() => {
handleChangeProps(initialProps);
setParamsResetKey(Date.now());
}}
>
<span className="animated-border alt-2" />
Reset Demo Params
</button>
</div>
}
>
{beforeDemo}
<Component {...props} />
{afterDemo}
</ErrorBoundary>
</div>
{withCallout && (
<p className="demo-callout" style={calloutStyle}>
<a href="https://www.front-kek.com/demos/chess-knight-moves" target="_blank">Tutorial</a>
</p>
)}
{!!Object.keys(paramsConfig)?.length && (
<DemoParams
key={'params-' + paramsResetKey}
initialProps={initialProps}
paramsConfig={paramsConfig}
paramsAutoopenDelay={paramsAutoopenDelay}
paramsInitialVisible={paramsInitialVisible}
onSubmit={handleChangeProps}
/>
)}
</div>
);
}
function DemoParams({
initialProps,
paramsConfig,
paramsAutoopenDelay = 0,
paramsInitialVisible = false,
onSubmit,
}: {
initialProps: PropsType;
paramsConfig?: ParamsConfigType;
paramsAutoopenDelay?: number;
paramsInitialVisible?: boolean;
onSubmit: (props: PropsType) => void;
}) {
const [formState, setFormState] = useState(initialProps);
const [paramsVisible, setParamsVisible] = useState(paramsInitialVisible);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (paramsAutoopenDelay) {
timeoutRef.current = setTimeout(() => {
setParamsVisible(true);
}, paramsAutoopenDelay);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const getBoundryValue = (key?: number | string) => {
if (typeof key === 'number') {
return key;
}
if (typeof key === 'string') {
return formState[key];
}
return undefined;
};
const handleSubmit = () => {
const newState = Object.keys(formState).reduce((acc, key) => {
let val = formState[key];
const { min, max } = paramsConfig?.[key] ?? {};
const maxVal = getBoundryValue(max);
if (typeof maxVal !== 'undefined' && val > maxVal) {
val = maxVal;
}
const minVal = getBoundryValue(min);
if (typeof minVal !== 'undefined' && val < minVal) {
val = minVal;
}
acc[key] = val;
return acc;
}, {} as PropsType);
setFormState(newState);
onSubmit(newState);
};
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
className={cn('demo-params', { 's--visible': paramsVisible })}
>
<div className="demo-params__inner">
<div className="demo-params__content">
<div className="demo-params__inputs">
{Object.keys(formState).map((key) => {
const {
label,
min,
max,
allowDecimals,
decimalStep = 0.1,
} = paramsConfig?.[key] ?? {};
return (
<label key={key}>
<p>{label}</p>
<input
key={key}
type="number"
value={formState[key]}
step={allowDecimals ? decimalStep : undefined}
min={getBoundryValue(min)}
max={getBoundryValue(max)}
onChange={(e) =>
setFormState({
...formState,
[key]: Number(e.currentTarget.value),
})
}
onKeyPress={preventNonNumbers(allowDecimals)}
/>
</label>
);
})}
</div>
<button type="submit" className="demo-params__submit">
<span className="animated-border alt-2" />
Submit
</button>
</div>
</div>
<button
type="button"
className="demo-params__toggle"
onClick={() => setParamsVisible(!paramsVisible)}
>
<span className="animated-border" />
Params
</button>
</form>
);
}
const initialProps: Props = {
boxSize: 60,
xSize: 6,
ySize: 6,
startX: 3,
startY: 3,
stepAnimationTime: 0.2,
};
function Demo() {
return (
<>
<DemoContainer
component={ChessKnightMovesDemo}
initialProps={initialProps as Record<string, number>}
paramsConfig={{
boxSize: { label: 'Box Size (px)', min: 10 },
xSize: { label: 'X Board Size', min: 5, max: 10 },
ySize: { label: 'Y Board Size', min: 5, max: 10 },
startX: { label: 'Start X Position', min: 1, max: 'xSize' },
startY: { label: 'Start Y Position', min: 1, max: 'ySize' },
stepAnimationTime: {
label: 'Animation Step Time (S)',
min: 0.05,
max: 2,
allowDecimals: true,
},
}}
paramsAutoopenDelay={8400}
/>
</>
);
}
ReactDOM.render(<Demo />, document.querySelector('#demo'));
View Compiled
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.