Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d7bdf2bd | ||
|
|
9b05b7fdaa | ||
|
|
ccbe4e530c | ||
|
|
bc6405aca3 | ||
|
|
fc4e8d4436 | ||
|
|
978ec843d3 | ||
|
|
c713fd8dd3 | ||
|
|
c14ae566e0 | ||
|
|
724bc749a2 |
29
.github/workflows/deploy.yml
vendored
Normal file
29
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1.4.2
|
||||
with:
|
||||
node-version: 14
|
||||
|
||||
- uses: bahmutov/npm-install@v1.4.1
|
||||
|
||||
- run: yarn build:demo
|
||||
env:
|
||||
BASE_PATHNAME: "snk"
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.1.1
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: packages/demo/dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
|
||||
79
.github/workflows/main.yml
vendored
79
.github/workflows/main.yml
vendored
@@ -1,83 +1,28 @@
|
||||
name: main
|
||||
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
# - run: sudo apt-get install gifsicle graphicsmagick
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-node@v1.4.2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
node-version: 14
|
||||
|
||||
- run: yarn type
|
||||
- run: yarn lint
|
||||
- run: yarn test --ci
|
||||
- uses: bahmutov/npm-install@v1.4.1
|
||||
|
||||
test-benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: ( cd packages/gif-creator ; yarn benchmark )
|
||||
|
||||
test-action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: update action.yml
|
||||
run: |
|
||||
sed -i "s/image: .*/image: Dockerfile/" action.yml
|
||||
# - run: yarn type
|
||||
# - run: yarn lint
|
||||
# - run: yarn test --ci
|
||||
# - run: yarn build:lib
|
||||
|
||||
- name: generate-snake-game-from-github-contribution-grid
|
||||
id: generate-snake
|
||||
uses: ./
|
||||
uses: Platane/snk@master
|
||||
with:
|
||||
github_user_name: platane
|
||||
gif_out_path: dist/github-contribution-grid-snake.gif
|
||||
svg_out_path: dist/github-contribution-grid-snake.svg
|
||||
|
||||
- name: ensure the generated file exists
|
||||
run: |
|
||||
ls -l ${{ steps.generate-snake.outputs.gif_out_path }}
|
||||
test -f ${{ steps.generate-snake.outputs.gif_out_path }}
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.5.0
|
||||
with:
|
||||
target_branch: output
|
||||
build_dir: dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
deploy-ghpages:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- run: yarn build:demo
|
||||
env:
|
||||
GITHUB_USER_CONTRIBUTION_API_ENDPOINT: https://snk-one.vercel.app/api/github-user-contribution/
|
||||
|
||||
- uses: crazy-max/ghaction-github-pages@v2.6.0
|
||||
if: success() && github.ref == 'refs/heads/master'
|
||||
with:
|
||||
target_branch: gh-pages
|
||||
build_dir: packages/demo/dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}
|
||||
- run: ls
|
||||
|
||||
87
.github/workflows/release.yml
vendored
87
.github/workflows/release.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# inputs:
|
||||
# description:
|
||||
# description: "Version description"
|
||||
# type: string
|
||||
# version:
|
||||
# description: "Version"
|
||||
# default: "0.0.1"
|
||||
# required: true
|
||||
# type: string
|
||||
# prerelease:
|
||||
# description: "Prerelease"
|
||||
# default: false
|
||||
# required: true
|
||||
# type: boolean
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: docker/setup-qemu-action@v1
|
||||
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# - name: prepare tags
|
||||
# id: tags
|
||||
# run: |
|
||||
# VERSION=$(npm pkg get version | tr -d '"')
|
||||
# BRANCH=$(echo $GITHUB_REF_NAME | sed "s/\//_/g")
|
||||
# echo "::set-output name=tag1::$GITHUB_SHA"
|
||||
# if [[ "$BRANCH" == 'master' ]]; then
|
||||
# echo "::set-output name=tag2::latest"
|
||||
# echo "::set-output name=tag3::$VERSION"
|
||||
# else
|
||||
# echo "::set-output name=tag2::$BRANCH"
|
||||
# echo "::set-output name=tag3::$BRANCH-$VERSION"
|
||||
# fi
|
||||
|
||||
- uses: docker/build-push-action@v2
|
||||
id: docker-build
|
||||
with:
|
||||
push: true
|
||||
tags: platane/snk:${{ github.sha }} platane/snk:${{ github.ref.name }}
|
||||
|
||||
- name: update action.yml
|
||||
run: |
|
||||
sed -i "s/image: .*/image: docker:\/\/platane\/snk:${{ steps.docker-build.outputs.digest }}/" action.yml
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: yarn
|
||||
node-version: 16
|
||||
|
||||
- name: bump version
|
||||
run: yarn version --no-git-tag-version --new-version ${{ github.ref.name }}
|
||||
|
||||
- name: build svg-only action
|
||||
run: |
|
||||
yarn install --frozen-lockfile
|
||||
yarn build:demo
|
||||
mv packages/demo/dist/* svg-only/
|
||||
|
||||
- name: push new commit
|
||||
uses: EndBug/add-and-commit@v7
|
||||
with:
|
||||
add: package.json svg-only action.yml
|
||||
message: 📦 ${{ github.event.inputs.version }}
|
||||
tag: v${{ github.event.inputs.version }}
|
||||
|
||||
- uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ github.event.inputs.version }}
|
||||
# release_name: Release ${{ github.event.inputs.version }}
|
||||
body: ${{ github.event.inputs.description }}
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,32 +1,15 @@
|
||||
FROM node:16-slim as builder
|
||||
FROM node:14-slim
|
||||
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
COPY tsconfig.json package.json yarn.lock ./generate-snake-game-from-github-contribution-grid/
|
||||
COPY packages ./generate-snake-game-from-github-contribution-grid/packages/
|
||||
|
||||
COPY tsconfig.json ./
|
||||
RUN ( cd ./generate-snake-game-from-github-contribution-grid ; yarn install --frozen-lockfile )
|
||||
|
||||
COPY packages packages
|
||||
RUN ( cd ./generate-snake-game-from-github-contribution-grid ; yarn build:action )
|
||||
|
||||
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \
|
||||
&& yarn install --frozen-lockfile \
|
||||
&& rm -r "$YARN_CACHE_FOLDER"
|
||||
|
||||
RUN yarn build:action
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
FROM node:16-slim
|
||||
|
||||
WORKDIR /action-release
|
||||
|
||||
RUN export YARN_CACHE_FOLDER="$(mktemp -d)" \
|
||||
&& yarn add canvas@2.9.1 gifsicle@5.3.0 --no-lockfile \
|
||||
&& rm -r "$YARN_CACHE_FOLDER"
|
||||
|
||||
COPY --from=builder /app/packages/action/dist/ /action-release/
|
||||
|
||||
CMD ["node", "/action-release/index.js"]
|
||||
CMD ["node", "./generate-snake-game-from-github-contribution-grid/packages/action/dist/index.js"]
|
||||
|
||||
|
||||
57
README.md
57
README.md
@@ -1,58 +1,3 @@
|
||||
# snk
|
||||
|
||||
[](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
|
||||

|
||||

|
||||
|
||||
Generates a snake game from a github user contributions graph
|
||||
|
||||

|
||||
|
||||
Pull a github user's contribution graph.
|
||||
Make it a snake Game, generate a snake path where the cells get eaten in an orderly fashion.
|
||||
|
||||
Generate a [gif](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.gif) or [svg](https://github.com/Platane/snk/raw/output/github-contribution-grid-snake.svg) image.
|
||||
|
||||
Available as github action. Automatically generate a new image at the end of the day. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme)
|
||||
|
||||
## Usage
|
||||
|
||||
**github action**
|
||||
|
||||
```yaml
|
||||
- uses: Platane/snk@master
|
||||
with:
|
||||
# github user name to read the contribution graph from (**required**)
|
||||
# using action context var `github.repository_owner` or specified user
|
||||
github_user_name: ${{ github.repository_owner }}
|
||||
|
||||
# path of the generated gif file
|
||||
# If left empty, the gif file will not be generated
|
||||
gif_out_path: dist/github-snake.gif
|
||||
|
||||
# path of the generated svg file
|
||||
# If left empty, the svg file will not be generated
|
||||
svg_out_path: dist/github-snake.svg
|
||||
```
|
||||
|
||||
> [example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)
|
||||
|
||||
**interactive demo**
|
||||
|
||||
<a href="https://platane.github.io/snk">
|
||||
<img height="300px" src="https://user-images.githubusercontent.com/1659820/121798244-7c86d700-cc25-11eb-8c1c-b8e65556ac0d.gif" ></img>
|
||||
</a>
|
||||
|
||||
[platane.github.io/snk](https://platane.github.io/snk)
|
||||
|
||||
**local**
|
||||
|
||||
```
|
||||
npm install
|
||||
|
||||
npm run dev:demo
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
[solver algorithm](./packages/solver/README.md)
|
||||
Generates a snake game from a github user contributions grid and output a screen capture as gif
|
||||
|
||||
27
action.yml
27
action.yml
@@ -1,26 +1,23 @@
|
||||
name: "generate-snake-game-from-github-contribution-grid"
|
||||
description: "Generates a snake game from a github user contributions grid. Output the animation as gif or svg"
|
||||
description: "Generates a snake game from a github user contributions grid and output a screen capture as gif"
|
||||
author: "platane"
|
||||
|
||||
outputs:
|
||||
gif_out_path:
|
||||
description: "path of the generated gif"
|
||||
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
# args:
|
||||
# - ${{ inputs.github_user_name }}
|
||||
# - ${{ inputs.gif_out_path }}
|
||||
|
||||
inputs:
|
||||
github_user_name:
|
||||
description: "github user name"
|
||||
required: true
|
||||
gif_out_path:
|
||||
description: "path of the generated gif file. If left empty, the gif file will not be generated."
|
||||
required: false
|
||||
default: null
|
||||
svg_out_path:
|
||||
description: "path of the generated svg file. If left empty, the svg file will not be generated."
|
||||
required: false
|
||||
default: null
|
||||
|
||||
outputs:
|
||||
gif_out_path:
|
||||
description: "path of the generated gif"
|
||||
svg_out_path:
|
||||
description: "path of the generated svg"
|
||||
required: false
|
||||
default: "./github-contribution-grid-snake.gif"
|
||||
|
||||
18
package.json
18
package.json
@@ -1,25 +1,23 @@
|
||||
{
|
||||
"name": "snk",
|
||||
"description": "Generates a snake game from a github user contributions grid",
|
||||
"version": "1.0.1",
|
||||
"description": "Generates a snake game from a github user contributions grid and output a screen capture as gif",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"repository": "github:platane/snk",
|
||||
"devDependencies": {
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "16.11.7",
|
||||
"jest": "27.5.1",
|
||||
"prettier": "2.6.0",
|
||||
"ts-jest": "27.1.3",
|
||||
"typescript": "4.6.2"
|
||||
"@types/jest": "26.0.4",
|
||||
"jest": "26.1.0",
|
||||
"prettier": "2.0.5",
|
||||
"ts-jest": "26.1.2",
|
||||
"typescript": "3.9.6"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/**"
|
||||
],
|
||||
"scripts": {
|
||||
"type": "tsc --noEmit",
|
||||
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/*/dist/**'",
|
||||
"lint": "yarn prettier -c '**/*.{ts,js,json,md,yml,yaml}' '!packages/action/dist/**' '!packages/demo/dist/**' '!packages/demo/webpack.config.js'",
|
||||
"test": "jest --verbose --passWithNoTests --no-cache",
|
||||
"dev:demo": "( cd packages/demo ; yarn dev )",
|
||||
"build:demo": "( cd packages/demo ; yarn build )",
|
||||
"build:action": "( cd packages/action ; yarn build )"
|
||||
}
|
||||
|
||||
3
packages/action/.gitignore
vendored
Normal file
3
packages/action/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
!dist
|
||||
!dist/build
|
||||
out.gif
|
||||
@@ -1,15 +0,0 @@
|
||||
# @snk/action
|
||||
|
||||
Contains the github action code.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Docker
|
||||
|
||||
Because the gif generation requires some native libs, we cannot use a node.js action.
|
||||
|
||||
Use a docker action instead, the image is created from the [Dockerfile](./Dockerfile).
|
||||
|
||||
It's published to [dockerhub](https://hub.docker.com/r/platane/snk) which makes for faster build ( compare to building the image when the action runs )
|
||||
|
||||
Notice that the [action.yml](../../action.yml) point to the latest version of the image. Which makes releasing sematic versioning of the action pointless. Which is probably fine for a wacky project like this one.
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,19 +1,5 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { generateContributionSnake } from "../generateContributionSnake";
|
||||
|
||||
(async () => {
|
||||
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
|
||||
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
|
||||
|
||||
const buffer = await generateContributionSnake("platane", {
|
||||
svg: true,
|
||||
gif: true,
|
||||
});
|
||||
|
||||
console.log("💾 writing to", outputSvg);
|
||||
fs.writeFileSync(outputSvg, buffer.svg);
|
||||
|
||||
console.log("💾 writing to", outputGif);
|
||||
fs.writeFileSync(outputGif, buffer.gif);
|
||||
})();
|
||||
generateContributionSnake("platane").then((buffer) => {
|
||||
process.stdout.write(buffer);
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { generateContributionSnake } from "../generateContributionSnake";
|
||||
|
||||
jest.setTimeout(2 * 60 * 1000);
|
||||
|
||||
const silent = (handler: () => void | Promise<void>) => async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = () => undefined;
|
||||
try {
|
||||
return await handler();
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
};
|
||||
|
||||
it(
|
||||
"should generate contribution snake",
|
||||
silent(async () => {
|
||||
const outputSvg = path.join(__dirname, "__snapshots__/out.svg");
|
||||
const outputGif = path.join(__dirname, "__snapshots__/out.gif");
|
||||
|
||||
console.log = () => undefined;
|
||||
const buffer = await generateContributionSnake("platane", {
|
||||
svg: true,
|
||||
gif: true,
|
||||
});
|
||||
|
||||
expect(buffer.svg).toBeDefined();
|
||||
expect(buffer.gif).toBeDefined();
|
||||
|
||||
console.log("💾 writing to", outputSvg);
|
||||
fs.writeFileSync(outputSvg, buffer.svg);
|
||||
|
||||
console.log("💾 writing to", outputGif);
|
||||
fs.writeFileSync(outputGif, buffer.gif);
|
||||
})
|
||||
);
|
||||
BIN
packages/action/dist/build/Release/canvas.node
vendored
Executable file
BIN
packages/action/dist/build/Release/canvas.node
vendored
Executable file
Binary file not shown.
BIN
packages/action/dist/build/Release/libcairo.so.2
vendored
Normal file
BIN
packages/action/dist/build/Release/libcairo.so.2
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libcroco-0.6.so.3
vendored
Normal file
BIN
packages/action/dist/build/Release/libcroco-0.6.so.3
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libexpat.so.1
vendored
Normal file
BIN
packages/action/dist/build/Release/libexpat.so.1
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libffi.so.6
vendored
Normal file
BIN
packages/action/dist/build/Release/libffi.so.6
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libfontconfig.so.1
vendored
Normal file
BIN
packages/action/dist/build/Release/libfontconfig.so.1
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libfreetype.so.6
vendored
Normal file
BIN
packages/action/dist/build/Release/libfreetype.so.6
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libfribidi.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libfribidi.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libgdk_pixbuf-2.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libgdk_pixbuf-2.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libgif.so.7
vendored
Normal file
BIN
packages/action/dist/build/Release/libgif.so.7
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libgio-2.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libgio-2.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libglib-2.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libglib-2.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libgmodule-2.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libgmodule-2.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libgobject-2.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libgobject-2.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libgthread-2.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libgthread-2.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libharfbuzz.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libharfbuzz.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libjpeg.so.62
vendored
Normal file
BIN
packages/action/dist/build/Release/libjpeg.so.62
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libpango-1.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libpango-1.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libpangocairo-1.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libpangocairo-1.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libpangoft2-1.0.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libpangoft2-1.0.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libpcre.so.1
vendored
Normal file
BIN
packages/action/dist/build/Release/libpcre.so.1
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libpixman-1.so.0
vendored
Normal file
BIN
packages/action/dist/build/Release/libpixman-1.so.0
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libpng16.so.16
vendored
Normal file
BIN
packages/action/dist/build/Release/libpng16.so.16
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/librsvg-2.so.2
vendored
Normal file
BIN
packages/action/dist/build/Release/librsvg-2.so.2
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libstdc++.so.6
vendored
Normal file
BIN
packages/action/dist/build/Release/libstdc++.so.6
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libxml2.so.2
vendored
Normal file
BIN
packages/action/dist/build/Release/libxml2.so.2
vendored
Normal file
Binary file not shown.
BIN
packages/action/dist/build/Release/libz.so.1
vendored
Normal file
BIN
packages/action/dist/build/Release/libz.so.1
vendored
Normal file
Binary file not shown.
174133
packages/action/dist/index.js
vendored
Normal file
174133
packages/action/dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
84
packages/action/dist/index1.js
vendored
Normal file
84
packages/action/dist/index1.js
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
const Canvas = require('./lib/canvas')
|
||||
const Image = require('./lib/image')
|
||||
const CanvasRenderingContext2D = require('./lib/context2d')
|
||||
const parseFont = require('./lib/parse-font')
|
||||
const packageJson = require('./package.json')
|
||||
const bindings = require('./lib/bindings')
|
||||
const fs = require('fs')
|
||||
const PNGStream = require('./lib/pngstream')
|
||||
const PDFStream = require('./lib/pdfstream')
|
||||
const JPEGStream = require('./lib/jpegstream')
|
||||
const DOMMatrix = require('./lib/DOMMatrix').DOMMatrix
|
||||
const DOMPoint = require('./lib/DOMMatrix').DOMPoint
|
||||
|
||||
function createCanvas (width, height, type) {
|
||||
return new Canvas(width, height, type)
|
||||
}
|
||||
|
||||
function createImageData (array, width, height) {
|
||||
return new bindings.ImageData(array, width, height)
|
||||
}
|
||||
|
||||
function loadImage (src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
|
||||
function cleanup () {
|
||||
image.onload = null
|
||||
image.onerror = null
|
||||
}
|
||||
|
||||
image.onload = () => { cleanup(); resolve(image) }
|
||||
image.onerror = (err) => { cleanup(); reject(err) }
|
||||
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve paths for registerFont. Must be called *before* creating a Canvas
|
||||
* instance.
|
||||
* @param src {string} Path to font file.
|
||||
* @param fontFace {{family: string, weight?: string, style?: string}} Object
|
||||
* specifying font information. `weight` and `style` default to `"normal"`.
|
||||
*/
|
||||
function registerFont (src, fontFace) {
|
||||
// TODO this doesn't need to be on Canvas; it should just be a static method
|
||||
// of `bindings`.
|
||||
return Canvas._registerFont(fs.realpathSync(src), fontFace)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Canvas,
|
||||
Context2d: CanvasRenderingContext2D, // Legacy/compat export
|
||||
CanvasRenderingContext2D,
|
||||
CanvasGradient: bindings.CanvasGradient,
|
||||
CanvasPattern: bindings.CanvasPattern,
|
||||
Image,
|
||||
ImageData: bindings.ImageData,
|
||||
PNGStream,
|
||||
PDFStream,
|
||||
JPEGStream,
|
||||
DOMMatrix,
|
||||
DOMPoint,
|
||||
|
||||
registerFont,
|
||||
parseFont,
|
||||
|
||||
createCanvas,
|
||||
createImageData,
|
||||
loadImage,
|
||||
|
||||
backends: bindings.Backends,
|
||||
|
||||
/** Library version. */
|
||||
version: packageJson.version,
|
||||
/** Cairo version. */
|
||||
cairoVersion: bindings.cairoVersion,
|
||||
/** jpeglib version. */
|
||||
jpegVersion: bindings.jpegVersion,
|
||||
/** gif_lib version. */
|
||||
gifVersion: bindings.gifVersion ? bindings.gifVersion.replace(/[^.\d]/g, '') : undefined,
|
||||
/** freetype version. */
|
||||
freetypeVersion: bindings.freetypeVersion
|
||||
}
|
||||
60
packages/action/dist/xhr-sync-worker.js
vendored
Normal file
60
packages/action/dist/xhr-sync-worker.js
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
/* eslint-disable no-process-exit */
|
||||
const util = require("util");
|
||||
const { JSDOM } = require("../../../..");
|
||||
const { READY_STATES } = require("./xhr-utils");
|
||||
const idlUtils = require("../generated/utils");
|
||||
const tough = require("tough-cookie");
|
||||
|
||||
const dom = new JSDOM();
|
||||
const xhr = new dom.window.XMLHttpRequest();
|
||||
const xhrImpl = idlUtils.implForWrapper(xhr);
|
||||
|
||||
const chunks = [];
|
||||
|
||||
process.stdin.on("data", chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
const flag = JSON.parse(buffer.toString());
|
||||
if (flag.body && flag.body.type === "Buffer" && flag.body.data) {
|
||||
flag.body = Buffer.from(flag.body.data);
|
||||
}
|
||||
if (flag.cookieJar) {
|
||||
flag.cookieJar = tough.CookieJar.fromJSON(flag.cookieJar);
|
||||
}
|
||||
|
||||
flag.synchronous = false;
|
||||
Object.assign(xhrImpl.flag, flag);
|
||||
const { properties } = xhrImpl;
|
||||
xhrImpl.readyState = READY_STATES.OPENED;
|
||||
try {
|
||||
xhr.addEventListener("loadend", () => {
|
||||
if (properties.error) {
|
||||
properties.error = properties.error.stack || util.inspect(properties.error);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({
|
||||
responseURL: xhrImpl.responseURL,
|
||||
status: xhrImpl.status,
|
||||
statusText: xhrImpl.statusText,
|
||||
properties
|
||||
}), () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}, false);
|
||||
xhr.send(flag.body);
|
||||
} catch (error) {
|
||||
properties.error += error.stack || util.inspect(error);
|
||||
process.stdout.write(JSON.stringify({
|
||||
responseURL: xhrImpl.responseURL,
|
||||
status: xhrImpl.status,
|
||||
statusText: xhrImpl.statusText,
|
||||
properties
|
||||
}), () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,53 +1,56 @@
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
import { userContributionToGrid } from "./userContributionToGrid";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { createGif } from "@snk/gif-creator";
|
||||
import { createSvg } from "../svg-creator";
|
||||
import { snake4 } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import { getGithubUserContribution, Cell } from "@snk/github-user-contribution";
|
||||
import { generateEmptyGrid } from "@snk/compute/generateGrid";
|
||||
import { setColor } from "@snk/compute/grid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
import { createGif } from "../gif-creator";
|
||||
|
||||
export const generateContributionSnake = async (
|
||||
userName: string,
|
||||
format: { svg?: boolean; gif?: boolean }
|
||||
) => {
|
||||
console.log("🎣 fetching github user contribution");
|
||||
export const userContributionToGrid = (cells: Cell[]) => {
|
||||
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
|
||||
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
|
||||
|
||||
const grid = generateEmptyGrid(width, height);
|
||||
for (const c of cells) setColor(grid, c.x, c.y, c.k === 0 ? null : c.k);
|
||||
|
||||
return grid;
|
||||
};
|
||||
|
||||
export const generateContributionSnake = async (userName: string) => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution(userName);
|
||||
|
||||
const grid = userContributionToGrid(cells, colorScheme);
|
||||
const snake = snake4;
|
||||
const grid0 = userContributionToGrid(cells);
|
||||
|
||||
const snake0 = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: colorScheme as any,
|
||||
colorDots: colorScheme,
|
||||
colorEmpty: colorScheme[0],
|
||||
colorSnake: "purple",
|
||||
cells,
|
||||
dark: {
|
||||
colorEmpty: "#161b22",
|
||||
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
|
||||
},
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
console.log("📡 computing best route");
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
const gifOptions = { delay: 10 };
|
||||
|
||||
const output: Record<string, Buffer | string> = {};
|
||||
const commands = computeBestRun(grid0, snake0, gameOptions);
|
||||
|
||||
if (format.gif) {
|
||||
console.log("📹 creating gif");
|
||||
output.gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
}
|
||||
const buffer = await createGif(
|
||||
grid0,
|
||||
snake0,
|
||||
commands,
|
||||
drawOptions,
|
||||
gameOptions,
|
||||
gifOptions
|
||||
);
|
||||
|
||||
if (format.svg) {
|
||||
console.log("🖌 creating svg");
|
||||
output.svg = createSvg(grid, chain, drawOptions, gifOptions);
|
||||
}
|
||||
|
||||
return output;
|
||||
return buffer;
|
||||
};
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as core from "@actions/core";
|
||||
import { generateContributionSnake } from "./generateContributionSnake";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const userName = core.getInput("github_user_name");
|
||||
const format = {
|
||||
svg: core.getInput("svg_out_path"),
|
||||
gif: core.getInput("gif_out_path"),
|
||||
};
|
||||
console.log("argv", process.argv);
|
||||
|
||||
const { svg, gif } = await generateContributionSnake(
|
||||
userName,
|
||||
format as any
|
||||
);
|
||||
console.log(core.getInput("user_name"));
|
||||
console.log(core.getInput("gif_out_path"));
|
||||
console.log("--");
|
||||
console.log("--");
|
||||
console.log(process.cwd());
|
||||
console.log("--");
|
||||
console.log(fs.readdirSync(process.cwd()));
|
||||
console.log("--");
|
||||
console.log("--");
|
||||
console.log(process.env.GITHUB_WORKSPACE);
|
||||
console.log("--");
|
||||
console.log(fs.readdirSync(process.cwd()));
|
||||
|
||||
if (svg) {
|
||||
fs.mkdirSync(path.dirname(format.svg), { recursive: true });
|
||||
fs.writeFileSync(format.svg, svg);
|
||||
core.setOutput("svg_out_path", format.svg);
|
||||
}
|
||||
if (gif) {
|
||||
fs.mkdirSync(path.dirname(format.gif), { recursive: true });
|
||||
fs.writeFileSync(format.gif, gif);
|
||||
core.setOutput("gif_out_path", format.gif);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const buffer = await generateContributionSnake(core.getInput("user_name"));
|
||||
fs.writeFileSync(core.getInput("gif_out_path"), buffer);
|
||||
} catch (e) {
|
||||
core.setFailed(`Action failed with "${e.message}"`);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
"name": "@snk/action",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@actions/core": "1.6.0",
|
||||
"@actions/core": "1.2.4",
|
||||
"@snk/gif-creator": "1.0.0",
|
||||
"@snk/github-user-contribution": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@zeit/ncc": "0.22.3",
|
||||
"ts-node": "10.7.0"
|
||||
"@zeit/ncc": "0.22.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts",
|
||||
"dev": "ts-node __tests__/dev.ts"
|
||||
"build": "ncc build ./index.ts --out dist",
|
||||
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { setColor, createEmptyGrid, setColorEmpty } from "@snk/types/grid";
|
||||
import type { Cell } from "@snk/github-user-contribution";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
|
||||
export const userContributionToGrid = (
|
||||
cells: Cell[],
|
||||
colorScheme: string[]
|
||||
) => {
|
||||
const width = Math.max(0, ...cells.map((c) => c.x)) + 1;
|
||||
const height = Math.max(0, ...cells.map((c) => c.y)) + 1;
|
||||
|
||||
const grid = createEmptyGrid(width, height);
|
||||
for (const c of cells) {
|
||||
const k = colorScheme.indexOf(c.color);
|
||||
if (k > 0) setColor(grid, c.x, c.y, k as Color);
|
||||
else setColorEmpty(grid, c.x, c.y);
|
||||
}
|
||||
|
||||
return grid;
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createEmptyGrid, setColor, getColor, isInside, Color } from "../grid";
|
||||
import { generateEmptyGrid } from "../generateGrid";
|
||||
import { setColor, getColor, isInside } from "../grid";
|
||||
|
||||
it("should set / get cell", () => {
|
||||
const grid = createEmptyGrid(2, 3);
|
||||
const grid = generateEmptyGrid(2, 3);
|
||||
|
||||
expect(getColor(grid, 0, 1)).toBe(0);
|
||||
expect(getColor(grid, 0, 1)).toBe(null);
|
||||
|
||||
setColor(grid, 0, 1, 1 as Color);
|
||||
setColor(grid, 0, 1, 1);
|
||||
|
||||
expect(getColor(grid, 0, 1)).toBe(1);
|
||||
});
|
||||
@@ -19,7 +20,7 @@ test.each([
|
||||
[2, 1, false],
|
||||
[0, 3, false],
|
||||
])("isInside", (x, y, output) => {
|
||||
const grid = createEmptyGrid(2, 3);
|
||||
const grid = generateEmptyGrid(2, 3);
|
||||
|
||||
expect(isInside(grid, x, y)).toBe(output);
|
||||
});
|
||||
24
packages/compute/__tests__/snake.spec.ts
Normal file
24
packages/compute/__tests__/snake.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { snakeSelfCollide } from "../snake";
|
||||
|
||||
test.each([
|
||||
[[{ x: 0, y: 0 }], false],
|
||||
[
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
],
|
||||
true,
|
||||
],
|
||||
[
|
||||
[
|
||||
{ x: 1, y: 7 },
|
||||
{ x: 0, y: 6 },
|
||||
{ x: 2, y: 8 },
|
||||
{ x: 1, y: 7 },
|
||||
{ x: 3, y: 9 },
|
||||
],
|
||||
true,
|
||||
],
|
||||
])("should report snake collision", (snake, collide) => {
|
||||
expect(snakeSelfCollide(snake)).toBe(collide);
|
||||
});
|
||||
94
packages/compute/__tests__/step.spec.ts
Normal file
94
packages/compute/__tests__/step.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { step } from "../step";
|
||||
import { generateEmptyGrid } from "../generateGrid";
|
||||
import { around4 } from "../point";
|
||||
import { setColor, getColor } from "../grid";
|
||||
|
||||
it("should move snake", () => {
|
||||
const grid = generateEmptyGrid(4, 3);
|
||||
const snake = [{ x: 1, y: 1 }];
|
||||
const direction = around4[0];
|
||||
const stack: number[] = [];
|
||||
const options = { maxSnakeLength: 5 };
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should move short snake", () => {
|
||||
const grid = generateEmptyGrid(8, 3);
|
||||
const snake = [{ x: 1, y: 1 }];
|
||||
const direction = around4[0];
|
||||
const stack: number[] = [];
|
||||
const options = { maxSnakeLength: 3 };
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(snake).toEqual([
|
||||
{ x: 5, y: 1 },
|
||||
{ x: 4, y: 1 },
|
||||
{ x: 3, y: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should pick up fruit", () => {
|
||||
const grid = generateEmptyGrid(4, 3);
|
||||
const snake = [{ x: 1, y: 1 }];
|
||||
const direction = around4[0];
|
||||
const stack: number[] = [];
|
||||
const options = { maxSnakeLength: 2 };
|
||||
setColor(grid, 3, 1, 9);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(getColor(grid, 3, 1)).toBe(9);
|
||||
expect(stack).toEqual([]);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
expect(getColor(grid, 3, 1)).toBe(null);
|
||||
expect(stack).toEqual([9]);
|
||||
});
|
||||
27
packages/compute/generateGrid.ts
Normal file
27
packages/compute/generateGrid.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Grid, Color } from "./grid";
|
||||
|
||||
const rand = (a: number, b: number) => Math.floor(Math.random() * (b - a)) + a;
|
||||
|
||||
export const generateEmptyGrid = (width: number, height: number) =>
|
||||
generateGrid(width, height, { colors: [], emptyP: 1 });
|
||||
|
||||
export const generateGrid = (
|
||||
width: number,
|
||||
height: number,
|
||||
options: { colors: Color[]; emptyP: number } = {
|
||||
colors: [1, 2, 3],
|
||||
emptyP: 2,
|
||||
}
|
||||
): Grid => {
|
||||
const g = {
|
||||
width,
|
||||
height,
|
||||
data: Array.from({ length: width * height }, () => {
|
||||
const x = rand(-options.emptyP, options.colors.length);
|
||||
|
||||
return x < 0 ? null : options.colors[x];
|
||||
}),
|
||||
};
|
||||
|
||||
return g;
|
||||
};
|
||||
30
packages/compute/grid.ts
Normal file
30
packages/compute/grid.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type Color = number;
|
||||
|
||||
export type Grid = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: (Color | null)[];
|
||||
};
|
||||
|
||||
export const getIndex = (grid: Grid, x: number, y: number) =>
|
||||
x * grid.height + y;
|
||||
|
||||
export const isInside = (grid: Grid, x: number, y: number) =>
|
||||
x >= 0 && y >= 0 && x < grid.width && y < grid.height;
|
||||
|
||||
export const isInsideLarge = (grid: Grid, m: number, x: number, y: number) =>
|
||||
x >= -m && y >= -m && x < grid.width + m && y < grid.height + m;
|
||||
|
||||
export const getColor = (grid: Grid, x: number, y: number) =>
|
||||
grid.data[getIndex(grid, x, y)];
|
||||
|
||||
export const copyGrid = (grid: Grid) => ({ ...grid, data: grid.data.slice() });
|
||||
|
||||
export const setColor = (
|
||||
grid: Grid,
|
||||
x: number,
|
||||
y: number,
|
||||
color: Color | null
|
||||
) => {
|
||||
grid.data[getIndex(grid, x, y)] = color;
|
||||
};
|
||||
44
packages/compute/index.ts
Normal file
44
packages/compute/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Grid, Color, copyGrid, isInsideLarge } from "./grid";
|
||||
import { Point, around4 } from "./point";
|
||||
import { stepSnake, step } from "./step";
|
||||
import { copySnake, snakeSelfCollide } from "./snake";
|
||||
|
||||
const isGridEmpty = (grid: Grid) => grid.data.every((x) => x === null);
|
||||
|
||||
export const computeBestRun = (
|
||||
grid: Grid,
|
||||
snake: Point[],
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
const g = copyGrid(grid);
|
||||
const s = copySnake(snake);
|
||||
const q: Color[] = [];
|
||||
|
||||
const commands: Point[] = [];
|
||||
|
||||
let u = 500;
|
||||
|
||||
while (!isGridEmpty(g) && u-- > 0) {
|
||||
let direction;
|
||||
|
||||
for (let k = 10; k--; ) {
|
||||
direction = around4[Math.floor(Math.random() * around4.length)];
|
||||
|
||||
const sn = copySnake(s);
|
||||
stepSnake(sn, direction, options);
|
||||
|
||||
if (isInsideLarge(g, 1, sn[0].x, sn[0].y) && !snakeSelfCollide(sn)) {
|
||||
break;
|
||||
} else {
|
||||
direction = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (direction !== undefined) {
|
||||
step(g, s, q, direction, options);
|
||||
commands.push(direction);
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
4
packages/compute/package.json
Normal file
4
packages/compute/package.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@snk/compute",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -5,6 +5,4 @@ export const around4 = [
|
||||
{ x: 0, y: -1 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
] as const;
|
||||
|
||||
export const pointEquals = (a: Point, b: Point) => a.x === b.x && a.y === b.y;
|
||||
];
|
||||
24
packages/compute/snake.ts
Normal file
24
packages/compute/snake.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Point } from "./point";
|
||||
|
||||
export const snakeSelfCollideNext = (
|
||||
snake: Point[],
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
const hx = snake[0].x + direction.x;
|
||||
const hy = snake[0].y + direction.y;
|
||||
|
||||
for (let i = 0; i < Math.min(options.maxSnakeLength, snake.length); i++)
|
||||
if (snake[i].x === hx && snake[i].y === hy) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const snakeSelfCollide = (snake: Point[]) => {
|
||||
for (let i = 1; i < snake.length; i++)
|
||||
if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
|
||||
48
packages/compute/step.ts
Normal file
48
packages/compute/step.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Grid, Color, getColor, isInside, setColor } from "./grid";
|
||||
import { Point } from "./point";
|
||||
|
||||
const moveSnake = (snake: Point[], headx: number, heady: number) => {
|
||||
for (let k = snake.length - 1; k > 0; k--) {
|
||||
snake[k].x = snake[k - 1].x;
|
||||
snake[k].y = snake[k - 1].y;
|
||||
}
|
||||
snake[0].x = headx;
|
||||
snake[0].y = heady;
|
||||
};
|
||||
|
||||
export const stepSnake = (
|
||||
snake: Point[],
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
const headx = snake[0].x + direction.x;
|
||||
const heady = snake[0].y + direction.y;
|
||||
|
||||
if (snake.length === options.maxSnakeLength) {
|
||||
moveSnake(snake, headx, heady);
|
||||
} else {
|
||||
snake.unshift({ x: headx, y: heady });
|
||||
}
|
||||
};
|
||||
|
||||
export const stepPicking = (grid: Grid, snake: Point[], stack: Color[]) => {
|
||||
if (isInside(grid, snake[0].x, snake[0].y)) {
|
||||
const c = getColor(grid, snake[0].x, snake[0].y);
|
||||
|
||||
if (c) {
|
||||
setColor(grid, snake[0].x, snake[0].y, null);
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const step = (
|
||||
grid: Grid,
|
||||
snake: Point[],
|
||||
stack: Color[],
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
stepSnake(snake, direction, options);
|
||||
stepPicking(grid, snake, stack);
|
||||
};
|
||||
1
packages/demo/.gitignore
vendored
Normal file
1
packages/demo/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
webpack.config.js
|
||||
@@ -1,3 +0,0 @@
|
||||
# @snk/demo
|
||||
|
||||
Contains various demo to test and validate some pieces of the algorithm.
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Color, Grid } from "@snk/types/grid";
|
||||
import { drawLerpWorld, drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
|
||||
export const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: {
|
||||
1: "#9be9a8",
|
||||
2: "#40c463",
|
||||
3: "#30a14e",
|
||||
4: "#216e39",
|
||||
},
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
dark: {
|
||||
colorEmpty: "#161b22",
|
||||
colorDots: { 1: "#01311f", 2: "#034525", 3: "#0f6d31", 4: "#00c647" },
|
||||
},
|
||||
};
|
||||
|
||||
const getPointedCell =
|
||||
(canvas: HTMLCanvasElement) =>
|
||||
({ pageX, pageY }: MouseEvent) => {
|
||||
const { left, top } = canvas.getBoundingClientRect();
|
||||
|
||||
const x = Math.floor((pageX - left) / drawOptions.sizeCell) - 1;
|
||||
const y = Math.floor((pageY - top) / drawOptions.sizeCell) - 2;
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const createCanvas = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const upscale = 2;
|
||||
const w = drawOptions.sizeCell * (width + 4);
|
||||
const h = drawOptions.sizeCell * (height + 4) + 200;
|
||||
canvas.width = w * upscale;
|
||||
canvas.height = h * upscale;
|
||||
canvas.style.width = w + "px";
|
||||
canvas.style.height = h + "px";
|
||||
canvas.style.display = "block";
|
||||
// canvas.style.pointerEvents = "none";
|
||||
|
||||
const cellInfo = document.createElement("div");
|
||||
cellInfo.style.height = "20px";
|
||||
|
||||
document.body.appendChild(cellInfo);
|
||||
document.body.appendChild(canvas);
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
const { x, y } = getPointedCell(canvas)(e);
|
||||
cellInfo.innerText = [x, y]
|
||||
.map((u) => u.toString().padStart(2, " "))
|
||||
.join(" / ");
|
||||
});
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.scale(upscale, upscale);
|
||||
|
||||
const draw = (grid: Grid, snake: Snake, stack: Color[]) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const drawLerp = (
|
||||
grid: Grid,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
k: number
|
||||
) => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
};
|
||||
|
||||
const highlightCell = (x: number, y: number, color = "orange") => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.fillRect((1 + x + 0.5) * 16 - 2, (2 + y + 0.5) * 16 - 2, 4, 4);
|
||||
};
|
||||
|
||||
return {
|
||||
draw,
|
||||
drawLerp,
|
||||
highlightCell,
|
||||
canvas,
|
||||
getPointedCell: getPointedCell(canvas),
|
||||
ctx,
|
||||
};
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { Color, copyGrid } from "@snk/types/grid";
|
||||
import { grid, snake } from "./sample";
|
||||
import { step } from "@snk/solver/step";
|
||||
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
|
||||
//
|
||||
// draw
|
||||
let k = 0;
|
||||
|
||||
const { canvas, draw } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const onChange = () => {
|
||||
const gridN = copyGrid(grid);
|
||||
const stack: Color[] = [];
|
||||
for (let i = 0; i <= k; i++) step(gridN, stack, chain[i]);
|
||||
|
||||
draw(gridN, chain[k], stack);
|
||||
};
|
||||
onChange();
|
||||
|
||||
const input = document.createElement("input") as any;
|
||||
input.type = "range";
|
||||
input.value = 0;
|
||||
input.step = 1;
|
||||
input.min = 0;
|
||||
input.max = chain.length - 1;
|
||||
input.style.width = "90%";
|
||||
input.addEventListener("input", () => {
|
||||
k = +input.value;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(input);
|
||||
window.addEventListener("click", (e) => {
|
||||
if (e.target === document.body || e.target === document.body.parentElement)
|
||||
input.focus();
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { getSnakeLength } from "@snk/types/snake";
|
||||
import { grid, snake } from "./sample";
|
||||
import { getColor } from "@snk/types/grid";
|
||||
import { getBestTunnel } from "@snk/solver/getBestTunnel";
|
||||
import { createOutside } from "@snk/solver/outside";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const ones: Point[] = [];
|
||||
|
||||
for (let x = 0; x < grid.width; x++)
|
||||
for (let y = 0; y < grid.height; y++)
|
||||
if (getColor(grid, x, y) === 1) ones.push({ x, y });
|
||||
|
||||
const tunnels = ones.map(({ x, y }) => ({
|
||||
x,
|
||||
y,
|
||||
tunnel: getBestTunnel(
|
||||
grid,
|
||||
createOutside(grid),
|
||||
x,
|
||||
y,
|
||||
3 as Color,
|
||||
getSnakeLength(snake)
|
||||
),
|
||||
}));
|
||||
|
||||
const onChange = () => {
|
||||
const k = +inputK.value;
|
||||
const i = +inputI.value;
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
|
||||
if (!tunnels[k]) return;
|
||||
|
||||
const { x, y, tunnel } = tunnels[k]!;
|
||||
|
||||
draw(grid, snake, []);
|
||||
|
||||
highlightCell(x, y, "red");
|
||||
|
||||
if (tunnel) {
|
||||
tunnel.forEach(({ x, y }) => highlightCell(x, y));
|
||||
highlightCell(x, y, "red");
|
||||
highlightCell(tunnel[i].x, tunnel[i].y, "blue");
|
||||
}
|
||||
};
|
||||
|
||||
const inputK = document.createElement("input") as any;
|
||||
inputK.type = "range";
|
||||
inputK.value = 0;
|
||||
inputK.step = 1;
|
||||
inputK.min = 0;
|
||||
inputK.max = tunnels ? tunnels.length - 1 : 0;
|
||||
inputK.style.width = "90%";
|
||||
inputK.style.padding = "20px 0";
|
||||
inputK.addEventListener("input", () => {
|
||||
inputI.value = 0;
|
||||
inputI.max = (tunnels[+inputK.value]?.tunnel?.length || 1) - 1;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(inputK);
|
||||
|
||||
const inputI = document.createElement("input") as any;
|
||||
inputI.type = "range";
|
||||
inputI.value = 0;
|
||||
inputI.step = 1;
|
||||
inputI.min = 0;
|
||||
inputI.max = (tunnels[+inputK.value]?.tunnel?.length || 1) - 1;
|
||||
inputI.style.width = "90%";
|
||||
inputI.style.padding = "20px 0";
|
||||
inputI.addEventListener("input", onChange);
|
||||
document.body.append(inputI);
|
||||
|
||||
onChange();
|
||||
@@ -1,59 +0,0 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { copySnake, snakeToCells } from "@snk/types/snake";
|
||||
import { grid, snake as snake0 } from "./sample";
|
||||
import { getPathTo } from "@snk/solver/getPathTo";
|
||||
|
||||
const { canvas, ctx, draw, getPointedCell, highlightCell } = createCanvas(grid);
|
||||
canvas.style.pointerEvents = "auto";
|
||||
|
||||
let snake = copySnake(snake0);
|
||||
let chain = [snake];
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
const { x, y } = getPointedCell(e);
|
||||
|
||||
chain = [...(getPathTo(grid, snake, x, y) || []), snake].reverse();
|
||||
|
||||
inputI.max = chain.length - 1;
|
||||
i = inputI.value = chain.length - 1;
|
||||
|
||||
onChange();
|
||||
});
|
||||
|
||||
canvas.addEventListener("click", () => {
|
||||
snake = chain.slice(-1)[0];
|
||||
|
||||
chain = [snake];
|
||||
inputI.max = chain.length - 1;
|
||||
i = inputI.value = chain.length - 1;
|
||||
|
||||
onChange();
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
const onChange = () => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
|
||||
draw(grid, chain[i], []);
|
||||
chain
|
||||
.map(snakeToCells)
|
||||
.flat()
|
||||
.forEach(({ x, y }) => highlightCell(x, y));
|
||||
};
|
||||
|
||||
onChange();
|
||||
|
||||
const inputI = document.createElement("input") as any;
|
||||
inputI.type = "range";
|
||||
inputI.value = 0;
|
||||
inputI.max = chain ? chain.length - 1 : 0;
|
||||
inputI.step = 1;
|
||||
inputI.min = 0;
|
||||
inputI.style.width = "90%";
|
||||
inputI.style.padding = "20px 0";
|
||||
inputI.addEventListener("input", () => {
|
||||
i = +inputI.value;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(inputI);
|
||||
@@ -1,41 +0,0 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { createSnakeFromCells, snakeToCells } from "@snk/types/snake";
|
||||
import { grid, snake } from "./sample";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
|
||||
const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
|
||||
canvas.style.pointerEvents = "auto";
|
||||
|
||||
const target = createSnakeFromCells(
|
||||
snakeToCells(snake).map((p) => ({ ...p, x: p.x - 1 }))
|
||||
);
|
||||
|
||||
let chain = [snake, ...getPathToPose(snake, target)!];
|
||||
|
||||
let i = 0;
|
||||
const onChange = () => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
|
||||
draw(grid, chain[i], []);
|
||||
chain
|
||||
.map(snakeToCells)
|
||||
.flat()
|
||||
.forEach(({ x, y }) => highlightCell(x, y));
|
||||
};
|
||||
|
||||
onChange();
|
||||
|
||||
const inputI = document.createElement("input") as any;
|
||||
inputI.type = "range";
|
||||
inputI.value = 0;
|
||||
inputI.max = chain ? chain.length - 1 : 0;
|
||||
inputI.step = 1;
|
||||
inputI.min = 0;
|
||||
inputI.style.width = "90%";
|
||||
inputI.style.padding = "20px 0";
|
||||
inputI.addEventListener("input", () => {
|
||||
i = +inputI.value;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(inputI);
|
||||
@@ -1,255 +0,0 @@
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { Color, copyGrid, Grid } from "@snk/types/grid";
|
||||
import { step } from "@snk/solver/step";
|
||||
import { isStableAndBound, stepSpring } from "./springUtils";
|
||||
import { Res } from "@snk/github-user-contribution";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import {
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
Options,
|
||||
} from "@snk/draw/drawWorld";
|
||||
import { userContributionToGrid } from "../action/userContributionToGrid";
|
||||
import { snake4 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
import { createSvg } from "../svg-creator";
|
||||
|
||||
const createForm = ({
|
||||
onSubmit,
|
||||
onChangeUserName,
|
||||
}: {
|
||||
onSubmit: (s: string) => Promise<void>;
|
||||
onChangeUserName: (s: string) => void;
|
||||
}) => {
|
||||
const form = document.createElement("form");
|
||||
form.style.position = "relative";
|
||||
form.style.display = "flex";
|
||||
form.style.flexDirection = "row";
|
||||
const input = document.createElement("input");
|
||||
input.addEventListener("input", () => onChangeUserName(input.value));
|
||||
input.style.padding = "16px";
|
||||
input.placeholder = "github user";
|
||||
const submit = document.createElement("button");
|
||||
submit.style.padding = "16px";
|
||||
submit.type = "submit";
|
||||
submit.innerText = "ok";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.style.position = "absolute";
|
||||
label.style.textAlign = "center";
|
||||
label.style.top = "60px";
|
||||
label.style.left = "0";
|
||||
label.style.right = "0";
|
||||
|
||||
form.appendChild(input);
|
||||
form.appendChild(submit);
|
||||
document.body.appendChild(form);
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(input.value).catch((err) => {
|
||||
label.innerText = "error :(";
|
||||
throw err;
|
||||
});
|
||||
|
||||
input.disabled = true;
|
||||
submit.disabled = true;
|
||||
form.appendChild(label);
|
||||
label.innerText = "loading ...";
|
||||
});
|
||||
|
||||
//
|
||||
// dispose
|
||||
const dispose = () => {
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
return { dispose };
|
||||
};
|
||||
|
||||
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
|
||||
|
||||
const createGithubProfile = () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.padding = "20px";
|
||||
container.style.opacity = "0";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.alignItems = "flex-start";
|
||||
const image = document.createElement("img");
|
||||
image.style.width = "100px";
|
||||
image.style.height = "100px";
|
||||
image.style.borderRadius = "50px";
|
||||
const name = document.createElement("a");
|
||||
name.style.padding = "4px 0 0 0";
|
||||
|
||||
document.body.appendChild(container);
|
||||
container.appendChild(image);
|
||||
container.appendChild(name);
|
||||
|
||||
image.addEventListener("load", () => {
|
||||
container.style.opacity = "1";
|
||||
});
|
||||
const onChangeUser = (userName: string) => {
|
||||
container.style.opacity = "0";
|
||||
name.innerText = userName;
|
||||
name.href = `https://github.com/${userName}`;
|
||||
image.src = `https://github.com/${userName}.png`;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
document.body.removeChild(container);
|
||||
};
|
||||
|
||||
return { dispose, onChangeUser };
|
||||
};
|
||||
|
||||
const createViewer = ({
|
||||
grid0,
|
||||
chain,
|
||||
drawOptions,
|
||||
}: {
|
||||
grid0: Grid;
|
||||
chain: Snake[];
|
||||
drawOptions: Options;
|
||||
}) => {
|
||||
//
|
||||
// canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const w = Math.min(width, window.innerWidth);
|
||||
const h = (height / width) * w;
|
||||
canvas.style.width = w + "px";
|
||||
canvas.style.height = h + "px";
|
||||
canvas.style.pointerEvents = "none";
|
||||
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
//
|
||||
// draw
|
||||
let animationFrame: number;
|
||||
const spring = { x: 0, v: 0, target: 0 };
|
||||
const springParams = { tension: 120, friction: 20, maxVelocity: 50 };
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const loop = () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
|
||||
stepSpring(spring, springParams, spring.target);
|
||||
const stable = isStableAndBound(spring, spring.target);
|
||||
|
||||
const grid = copyGrid(grid0);
|
||||
const stack: Color[] = [];
|
||||
for (let i = 0; i < Math.min(chain.length, spring.x); i++)
|
||||
step(grid, stack, chain[i]);
|
||||
|
||||
const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)];
|
||||
const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)];
|
||||
const k = spring.x % 1;
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawLerpWorld(ctx, grid, snake0, snake1, stack, k, drawOptions);
|
||||
|
||||
if (!stable) animationFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
|
||||
//
|
||||
// controls
|
||||
const input = document.createElement("input") as any;
|
||||
input.type = "range";
|
||||
input.value = 0;
|
||||
input.step = 1;
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.style.width = "calc( 100% - 20px )";
|
||||
input.addEventListener("input", () => {
|
||||
spring.target = +input.value;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(loop);
|
||||
});
|
||||
const onClickBackground = (e: MouseEvent) => {
|
||||
if (e.target === document.body || e.target === document.body.parentElement)
|
||||
input.focus();
|
||||
};
|
||||
window.addEventListener("click", onClickBackground);
|
||||
document.body.append(input);
|
||||
|
||||
//
|
||||
// svg
|
||||
const svgLink = document.createElement("a");
|
||||
const svgString = createSvg(grid0, chain, drawOptions, {
|
||||
frameDuration: 100,
|
||||
});
|
||||
const svgImageUri = `data:image/*;charset=utf-8;base64,${btoa(svgString)}`;
|
||||
svgLink.href = svgImageUri;
|
||||
svgLink.innerText = "github-user-contribution.svg";
|
||||
svgLink.download = "github-user-contribution.svg";
|
||||
svgLink.addEventListener("click", (e) => {
|
||||
const w = window.open("")!;
|
||||
w.document.write(
|
||||
`<a href="${svgImageUri}" download="github-user-contribution.svg">` +
|
||||
svgString +
|
||||
"<a/>"
|
||||
);
|
||||
e.preventDefault();
|
||||
});
|
||||
svgLink.style.padding = "20px";
|
||||
svgLink.style.paddingTop = "60px";
|
||||
svgLink.style.alignSelf = "flex-start";
|
||||
document.body.append(svgLink);
|
||||
|
||||
//
|
||||
// dispose
|
||||
const dispose = () => {
|
||||
window.removeEventListener("click", onClickBackground);
|
||||
cancelAnimationFrame(animationFrame);
|
||||
document.body.removeChild(canvas);
|
||||
document.body.removeChild(input);
|
||||
document.body.removeChild(svgLink);
|
||||
};
|
||||
|
||||
return { dispose };
|
||||
};
|
||||
|
||||
const onSubmit = async (userName: string) => {
|
||||
const res = await fetch(
|
||||
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT + userName
|
||||
);
|
||||
const { cells, colorScheme } = (await res.json()) as Res;
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: colorScheme as any,
|
||||
colorEmpty: colorScheme[0],
|
||||
colorSnake: "purple",
|
||||
cells,
|
||||
};
|
||||
|
||||
const grid = userContributionToGrid(cells, colorScheme);
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
dispose();
|
||||
|
||||
createViewer({ grid0: grid, chain, drawOptions });
|
||||
};
|
||||
|
||||
const profile = createGithubProfile();
|
||||
const { dispose } = createForm({
|
||||
onSubmit,
|
||||
onChangeUserName: profile.onChangeUser,
|
||||
});
|
||||
|
||||
document.body.style.margin = "0";
|
||||
document.body.style.display = "flex";
|
||||
document.body.style.flexDirection = "column";
|
||||
document.body.style.alignItems = "center";
|
||||
document.body.style.justifyContent = "center";
|
||||
document.body.style.height = "100%";
|
||||
document.body.style.width = "100%";
|
||||
document.body.style.position = "absolute";
|
||||
@@ -1,9 +0,0 @@
|
||||
[
|
||||
"interactive",
|
||||
"getBestRoute",
|
||||
"getBestTunnel",
|
||||
"outside",
|
||||
"getPathToPose",
|
||||
"getPathTo",
|
||||
"svg"
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
import "./menu";
|
||||
import { createCanvas } from "./canvas";
|
||||
import { grid } from "./sample";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
import { createOutside, isOutside } from "@snk/solver/outside";
|
||||
|
||||
const { canvas, ctx, draw, highlightCell } = createCanvas(grid);
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
let k = 0;
|
||||
|
||||
const onChange = () => {
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
|
||||
draw(grid, [] as any, []);
|
||||
|
||||
const outside = createOutside(grid, k as Color);
|
||||
|
||||
for (let x = outside.width; x--; )
|
||||
for (let y = outside.height; y--; )
|
||||
if (isOutside(outside, x, y)) highlightCell(x, y);
|
||||
};
|
||||
|
||||
onChange();
|
||||
|
||||
const inputK = document.createElement("input") as any;
|
||||
inputK.type = "range";
|
||||
inputK.value = 0;
|
||||
inputK.step = 1;
|
||||
inputK.min = 0;
|
||||
inputK.max = 4;
|
||||
inputK.style.width = "90%";
|
||||
inputK.style.padding = "20px 0";
|
||||
inputK.addEventListener("input", () => {
|
||||
k = +inputK.value;
|
||||
onChange();
|
||||
});
|
||||
document.body.append(inputK);
|
||||
window.addEventListener("click", (e) => {
|
||||
if (e.target === document.body || e.target === document.body.parentElement)
|
||||
inputK.focus();
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import "./menu";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { createSvg } from "../svg-creator";
|
||||
import { grid, snake } from "./sample";
|
||||
import { drawOptions } from "./canvas";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
|
||||
const chain = getBestRoute(grid, snake);
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
(async () => {
|
||||
const svg = await createSvg(grid, chain, drawOptions, { frameDuration: 200 });
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svg;
|
||||
document.body.appendChild(container);
|
||||
})();
|
||||
81
packages/demo/index.ts
Normal file
81
packages/demo/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// import { generateGrid } from "@snk/compute/generateGrid";
|
||||
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { Color, copyGrid } from "@snk/compute/grid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
import { step } from "@snk/compute/step";
|
||||
import { drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Point } from "@snk/compute/point";
|
||||
|
||||
const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
|
||||
|
||||
export const run = async () => {
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
const grid0 = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
|
||||
const snake0 = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
const stack0: Color[] = [];
|
||||
|
||||
const chain = computeBestRun(grid0, snake0, gameOptions);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = drawOptions.sizeCell * (grid0.width + 4);
|
||||
canvas.height = drawOptions.sizeCell * (grid0.height + 4) + 100;
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const update = (n: number) => {
|
||||
const snake = copySnake(snake0);
|
||||
const stack = stack0.slice();
|
||||
const grid = copyGrid(grid0);
|
||||
|
||||
for (let i = 0; i < n; i++) step(grid, snake, stack, chain[i], gameOptions);
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const input: any = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.style.width = "100%";
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.step = 1;
|
||||
input.value = 0;
|
||||
input.addEventListener("input", () => update(+input.value));
|
||||
document.addEventListener("click", () => input.focus());
|
||||
|
||||
document.body.appendChild(input);
|
||||
|
||||
update(+input.value);
|
||||
|
||||
// while (chain.length) {
|
||||
// await wait(100);
|
||||
|
||||
// step(grid, snake, stack, chain.shift()!, gameOptions);
|
||||
|
||||
// ctx.clearRect(0, 0, 9999, 9999);
|
||||
// drawWorld(ctx, grid, snake, stack, options);
|
||||
// }
|
||||
|
||||
// const wait = (delay = 0) => new Promise((r) => setTimeout(r, delay));
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -1,36 +0,0 @@
|
||||
import { GUI } from "dat.gui";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import * as snakes from "@snk/types/__fixtures__/snake";
|
||||
import { grid, snake } from "./sample";
|
||||
|
||||
const demos: string[] = require("./demo.json");
|
||||
|
||||
export const gui = new GUI();
|
||||
|
||||
const config = {
|
||||
snake: Object.entries(snakes).find(([_, s]) => s === snake)![0],
|
||||
grid: Object.entries(grids).find(([_, s]) => s === grid)![0],
|
||||
demo: demos[0],
|
||||
};
|
||||
{
|
||||
const d = window.location.pathname.match(/(\w+)\.html/);
|
||||
if (d && demos.includes(d[1])) config.demo = d[1];
|
||||
}
|
||||
|
||||
const onChange = () => {
|
||||
const search = new URLSearchParams({
|
||||
snake: config.snake,
|
||||
grid: config.grid,
|
||||
}).toString();
|
||||
|
||||
const url = new URL(
|
||||
config.demo + ".html?" + search,
|
||||
window.location.href
|
||||
).toString();
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
gui.add(config, "demo", demos).onChange(onChange);
|
||||
gui.add(config, "grid", Object.keys(grids)).onChange(onChange);
|
||||
gui.add(config, "snake", Object.keys(snakes)).onChange(onChange);
|
||||
@@ -2,23 +2,18 @@
|
||||
"name": "@snk/demo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/draw": "1.0.0",
|
||||
"@snk/solver": "1.0.0",
|
||||
"canvas": "2.9.1",
|
||||
"gifsicle": "5.3.0"
|
||||
"@snk/compute": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dat.gui": "0.7.7",
|
||||
"dat.gui": "0.7.7",
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"ts-loader": "9.2.6",
|
||||
"ts-node": "10.7.0",
|
||||
"webpack": "5.70.0",
|
||||
"webpack-cli": "4.9.2",
|
||||
"webpack-dev-server": "4.7.4"
|
||||
"webpack": "4.43.0",
|
||||
"webpack-cli": "3.3.12",
|
||||
"webpack-dev-server": "3.11.0",
|
||||
"ts-loader": "8.0.1",
|
||||
"html-webpack-plugin": "4.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"dev": "webpack serve"
|
||||
"prepare": "tsc webpack.config.ts",
|
||||
"build": "yarn prepare ; webpack",
|
||||
"dev": "yarn prepare ; webpack-dev-server --port ${PORT-3000}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import * as snakes from "@snk/types/__fixtures__/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
import type { Grid } from "@snk/types/grid";
|
||||
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
|
||||
const gLabel = sp.get("grid") || "simple";
|
||||
const sLabel = sp.get("snake") || "snake3";
|
||||
|
||||
//@ts-ignore
|
||||
export const grid: Grid = grids[gLabel] || grids.simple;
|
||||
//@ts-ignore
|
||||
export const snake: Snake = snakes[sLabel] || snakes.snake3;
|
||||
@@ -1,63 +0,0 @@
|
||||
const epsilon = 0.01;
|
||||
|
||||
export const clamp = (a: number, b: number) => (x: number) =>
|
||||
Math.max(a, Math.min(b, x));
|
||||
|
||||
/**
|
||||
* step the spring, mutate the state to reflect the state at t+dt
|
||||
*
|
||||
*/
|
||||
const stepSpringOne = (
|
||||
s: { x: number; v: number },
|
||||
{
|
||||
tension,
|
||||
friction,
|
||||
maxVelocity = Infinity,
|
||||
}: { tension: number; friction: number; maxVelocity?: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
) => {
|
||||
const a = -tension * (s.x - target) - friction * s.v;
|
||||
|
||||
s.v += a * dt;
|
||||
s.v = clamp(-maxVelocity / dt, maxVelocity / dt)(s.v);
|
||||
s.x += s.v * dt;
|
||||
};
|
||||
|
||||
/**
|
||||
* return true if the spring is to be considered in a stable state
|
||||
* ( close enough to the target and with a small enough velocity )
|
||||
*/
|
||||
export const isStable = (
|
||||
s: { x: number; v: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
) => Math.abs(s.x - target) < epsilon && Math.abs(s.v * dt) < epsilon;
|
||||
|
||||
export const isStableAndBound = (
|
||||
s: { x: number; v: number },
|
||||
target: number,
|
||||
dt?: number
|
||||
) => {
|
||||
const stable = isStable(s, target, dt);
|
||||
if (stable) {
|
||||
s.x = target;
|
||||
s.v = 0;
|
||||
}
|
||||
return stable;
|
||||
};
|
||||
|
||||
export const stepSpring = (
|
||||
s: { x: number; v: number },
|
||||
params: { tension: number; friction: number; maxVelocity?: number },
|
||||
target: number,
|
||||
dt = 1 / 60
|
||||
) => {
|
||||
const interval = 1 / 60;
|
||||
|
||||
while (dt > 0) {
|
||||
stepSpringOne(s, params, target, Math.min(interval, dt));
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
dt -= interval;
|
||||
}
|
||||
};
|
||||
@@ -1,75 +1,47 @@
|
||||
import path from "path";
|
||||
import HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
import * as path from "path";
|
||||
|
||||
import type { Configuration as WebpackConfiguration } from "webpack";
|
||||
import type { Configuration as WebpackDevServerConfiguration } from "webpack-dev-server";
|
||||
import webpack from "webpack";
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
// @ts-ignore
|
||||
import * as HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
import type { Configuration } from "webpack";
|
||||
|
||||
const demos: string[] = require("./demo.json");
|
||||
const basePathname = (process.env.BASE_PATHNAME || "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
|
||||
const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
|
||||
open: { target: demos[1] + ".html" },
|
||||
onAfterSetupMiddleware: ({ app }) => {
|
||||
app!.get("/api/github-user-contribution/:userName", async (req, res) => {
|
||||
const userName: string = req.params.userName;
|
||||
res.send(await getGithubUserContribution(userName));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const webpackConfiguration: WebpackConfiguration = {
|
||||
const config: Configuration = {
|
||||
mode: "development",
|
||||
entry: Object.fromEntries(
|
||||
demos.map((demo: string) => [demo, `./demo.${demo}`])
|
||||
),
|
||||
target: ["web", "es2019"],
|
||||
entry: "./index",
|
||||
resolve: { extensions: [".ts", ".js"] },
|
||||
output: {
|
||||
path: path.join(__dirname, "dist"),
|
||||
filename: "[contenthash].js",
|
||||
publicPath: "/" + basePathname.map((x) => x + "/").join(""),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
exclude: /node_modules/,
|
||||
test: /\.ts$/,
|
||||
test: /\.(js|ts)$/,
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
lib: ["dom", "es2020"],
|
||||
target: "es2019",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
...demos.map(
|
||||
(demo) =>
|
||||
new HtmlWebpackPlugin({
|
||||
title: "snk - " + demo,
|
||||
filename: `${demo}.html`,
|
||||
chunks: [demo],
|
||||
})
|
||||
),
|
||||
// game
|
||||
new HtmlWebpackPlugin({
|
||||
title: "snk - " + demos[0],
|
||||
filename: `index.html`,
|
||||
chunks: [demos[0]],
|
||||
}),
|
||||
new webpack.EnvironmentPlugin({
|
||||
GITHUB_USER_CONTRIBUTION_API_ENDPOINT:
|
||||
process.env.GITHUB_USER_CONTRIBUTION_API_ENDPOINT ??
|
||||
"/api/github-user-contribution/",
|
||||
title: "demo",
|
||||
filename: "index.html",
|
||||
meta: {
|
||||
viewport: "width=device-width, initial-scale=1, shrink-to-fit=no",
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
devtool: false,
|
||||
stats: "errors-only",
|
||||
|
||||
// @ts-ignore
|
||||
devServer: {},
|
||||
};
|
||||
|
||||
export default {
|
||||
...webpackConfiguration,
|
||||
devServer: webpackDevServerConfiguration,
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# @snk/draw
|
||||
|
||||
Draw grids and snakes on a canvas.
|
||||
@@ -1,86 +0,0 @@
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import type { Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorBorder: string;
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
};
|
||||
|
||||
const isInsideCircle = (x: number, y: number, r: number) => {
|
||||
const l = 6;
|
||||
let k = 0;
|
||||
for (let dx = 0; dx < l; dx++)
|
||||
for (let dy = 0; dy < l; dy++) {
|
||||
const ux = x + (dx + 0.5) / l;
|
||||
const uy = y + (dy + 0.5) / l;
|
||||
|
||||
if (ux * ux + uy * uy < r * r) k++;
|
||||
}
|
||||
|
||||
return k > l * l * 0.6;
|
||||
};
|
||||
|
||||
export const getCellPath = (n: number): Point[] => {
|
||||
const l = Math.ceil(Math.sqrt(n));
|
||||
|
||||
const cells = [];
|
||||
|
||||
for (let x = -l; x <= l; x++)
|
||||
for (let y = -l; y <= l; y++) {
|
||||
const a = (Math.atan2(y, x) + (5 * Math.PI) / 2) % (Math.PI * 2);
|
||||
|
||||
let r = 0;
|
||||
|
||||
while (!isInsideCircle(x, y, r + 0.5)) r++;
|
||||
|
||||
cells.push({ x, y, f: r * 100 + a });
|
||||
}
|
||||
|
||||
return cells.sort((a, b) => a.f - b.f).slice(0, n);
|
||||
};
|
||||
|
||||
export const cellPath = getCellPath(52 * 7 + 5);
|
||||
|
||||
export const getCircleSize = (n: number) => {
|
||||
const c = cellPath.slice(0, n);
|
||||
const xs = c.map((p) => p.x);
|
||||
const ys = c.map((p) => p.y);
|
||||
|
||||
return {
|
||||
max: { x: Math.max(0, ...xs), y: Math.max(0, ...ys) },
|
||||
min: { x: Math.min(0, ...xs), y: Math.min(0, ...ys) },
|
||||
};
|
||||
};
|
||||
|
||||
export const drawCircleStack = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stack: Color[],
|
||||
o: Options
|
||||
) => {
|
||||
for (let i = stack.length; i--; ) {
|
||||
const { x, y } = cellPath[i];
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
);
|
||||
|
||||
//@ts-ignore
|
||||
ctx.fillStyle = o.colorDots[stack[i]];
|
||||
ctx.strokeStyle = o.colorBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { getColor } from "@snk/types/grid";
|
||||
import { Grid, getColor, Color } from "@snk/compute/grid";
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import type { Grid, Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
|
||||
type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
@@ -10,7 +8,6 @@ type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
cells?: Point[];
|
||||
};
|
||||
|
||||
export const drawGrid = (
|
||||
@@ -20,28 +17,26 @@ export const drawGrid = (
|
||||
) => {
|
||||
for (let x = grid.width; x--; )
|
||||
for (let y = grid.height; y--; ) {
|
||||
if (!o.cells || o.cells.some((c) => c.x === x && c.y === y)) {
|
||||
const c = getColor(grid, x, y);
|
||||
// @ts-ignore
|
||||
const color = !c ? o.colorEmpty : o.colorDots[c];
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
);
|
||||
const c = getColor(grid, x, y);
|
||||
// @ts-ignore
|
||||
const color = c === null ? o.colorEmpty : o.colorDots[c];
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
x * o.sizeCell + (o.sizeCell - o.sizeDot) / 2,
|
||||
y * o.sizeCell + (o.sizeCell - o.sizeDot) / 2
|
||||
);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = o.colorBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = o.colorBorder;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
pathRoundedRect(ctx, o.sizeDot, o.sizeDot, o.sizeBorderRadius);
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import { snakeToCells } from "@snk/types/snake";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
|
||||
type Options = {
|
||||
colorSnake: string;
|
||||
sizeCell: number;
|
||||
};
|
||||
|
||||
export const drawSnake = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
snake: Snake,
|
||||
o: Options
|
||||
) => {
|
||||
const cells = snakeToCells(snake);
|
||||
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const u = (i + 1) * 0.6;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.colorSnake;
|
||||
ctx.translate(cells[i].x * o.sizeCell + u, cells[i].y * o.sizeCell + u);
|
||||
ctx.beginPath();
|
||||
pathRoundedRect(
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const lerp = (k: number, a: number, b: number) => (1 - k) * a + k * b;
|
||||
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
|
||||
|
||||
export const drawSnakeLerp = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
k: number,
|
||||
o: Options
|
||||
) => {
|
||||
const m = 0.8;
|
||||
const n = snake0.length / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const u = (i + 1) * 0.6 * (o.sizeCell / 16);
|
||||
|
||||
const a = (1 - m) * (i / Math.max(n - 1, 1));
|
||||
const ki = clamp((k - a) / m, 0, 1);
|
||||
|
||||
const x = lerp(ki, snake0[i * 2 + 0], snake1[i * 2 + 0]) - 2;
|
||||
const y = lerp(ki, snake0[i * 2 + 1], snake1[i * 2 + 1]) - 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.colorSnake;
|
||||
ctx.translate(x * o.sizeCell + u, y * o.sizeCell + u);
|
||||
ctx.beginPath();
|
||||
pathRoundedRect(
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Grid, Color } from "@snk/compute/grid";
|
||||
import { pathRoundedRect } from "./pathRoundedRect";
|
||||
import { Point } from "@snk/compute/point";
|
||||
import { drawGrid } from "./drawGrid";
|
||||
import { drawSnake, drawSnakeLerp } from "./drawSnake";
|
||||
import type { Grid, Color } from "@snk/types/grid";
|
||||
import type { Point } from "@snk/types/point";
|
||||
import type { Snake } from "@snk/types/snake";
|
||||
|
||||
export type Options = {
|
||||
type Options = {
|
||||
colorDots: Record<Color, string>;
|
||||
colorEmpty: string;
|
||||
colorBorder: string;
|
||||
@@ -12,85 +11,53 @@ export type Options = {
|
||||
sizeCell: number;
|
||||
sizeDot: number;
|
||||
sizeBorderRadius: number;
|
||||
cells?: Point[];
|
||||
};
|
||||
|
||||
export const drawStack = (
|
||||
export const drawSnake = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stack: Color[],
|
||||
max: number,
|
||||
width: number,
|
||||
o: { colorDots: Record<Color, string> }
|
||||
snake: Point[],
|
||||
o: Options
|
||||
) => {
|
||||
ctx.save();
|
||||
for (let i = 0; i < snake.length; i++) {
|
||||
const u = (i + 1) * 0.6;
|
||||
|
||||
const m = width / max;
|
||||
|
||||
for (let i = 0; i < stack.length; i++) {
|
||||
// @ts-ignore
|
||||
ctx.fillStyle = o.colorDots[stack[i]];
|
||||
ctx.fillRect(i * m, 0, m + width * 0.005, 10);
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.colorSnake;
|
||||
ctx.translate(snake[i].x * o.sizeCell + u, snake[i].y * o.sizeCell + u);
|
||||
ctx.beginPath();
|
||||
pathRoundedRect(
|
||||
ctx,
|
||||
o.sizeCell - u * 2,
|
||||
o.sizeCell - u * 2,
|
||||
(o.sizeCell - u * 2) * 0.25
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
export const drawWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
snake: Snake,
|
||||
snake: Point[],
|
||||
stack: Color[],
|
||||
o: Options
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
ctx.translate(2 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawSnake(ctx, snake, o);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
const m = 5;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(o.sizeCell, (grid.height + 4) * o.sizeCell);
|
||||
|
||||
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
|
||||
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// ctx.save();
|
||||
// ctx.translate(o.sizeCell + 100, (grid.height + 4) * o.sizeCell + 100);
|
||||
// ctx.scale(0.6, 0.6);
|
||||
// drawCircleStack(ctx, stack, o);
|
||||
// ctx.restore();
|
||||
};
|
||||
|
||||
export const drawLerpWorld = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
grid: Grid,
|
||||
snake0: Snake,
|
||||
snake1: Snake,
|
||||
stack: Color[],
|
||||
k: number,
|
||||
o: Options
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawSnakeLerp(ctx, snake0, snake1, k, o);
|
||||
|
||||
ctx.translate(0, (grid.height + 2) * o.sizeCell);
|
||||
|
||||
const max = grid.data.reduce((sum, x) => sum + +!!x, stack.length);
|
||||
drawStack(ctx, stack, max, grid.width * o.sizeCell, o);
|
||||
|
||||
for (let i = 0; i < stack.length; i++) {
|
||||
ctx.fillStyle = o.colorDots[stack[i]];
|
||||
ctx.fillRect(i * m, 0, m, 10);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
export const getCanvasWorldSize = (grid: Grid, o: { sizeCell: number }) => {
|
||||
const width = o.sizeCell * (grid.width + 2);
|
||||
const height = o.sizeCell * (grid.height + 4) + 30;
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"name": "@snk/draw",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/solver": "1.0.0"
|
||||
"@snk/compute": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/gif-creator/.gitignore
vendored
Normal file
1
packages/gif-creator/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
out.gif
|
||||
@@ -1,5 +0,0 @@
|
||||
# @snk/gif-creator
|
||||
|
||||
Generate a gif file from the grid and snake path.
|
||||
|
||||
Relies on graphics magic and gifsicle binaries.
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,77 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import { performance } from "perf_hooks";
|
||||
import { createSnakeFromCells } from "@snk/types/snake";
|
||||
import { realistic as grid } from "@snk/types/__fixtures__/grid";
|
||||
import { createGif } from "..";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { getPathToPose } from "@snk/solver/getPathToPose";
|
||||
|
||||
let snake = createSnakeFromCells(
|
||||
Array.from({ length: 4 }, (_, i) => ({ x: i, y: -1 }))
|
||||
);
|
||||
|
||||
// const chain = [snake];
|
||||
// for (let y = -1; y < grid.height; y++) {
|
||||
// snake = nextSnake(snake, 0, 1);
|
||||
// chain.push(snake);
|
||||
|
||||
// for (let x = grid.width - 1; x--; ) {
|
||||
// snake = nextSnake(snake, (y + 100) % 2 ? 1 : -1, 0);
|
||||
// chain.push(snake);
|
||||
// }
|
||||
// }
|
||||
|
||||
const chain = getBestRoute(grid, snake)!;
|
||||
chain.push(...getPathToPose(chain.slice(-1)[0], snake)!);
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 100, step: 1 };
|
||||
|
||||
(async () => {
|
||||
for (
|
||||
let length = 10;
|
||||
length < chain.length;
|
||||
length += Math.floor((chain.length - 10) / 3 / 10) * 10
|
||||
) {
|
||||
const stats: number[] = [];
|
||||
|
||||
let buffer: Buffer;
|
||||
const start = Date.now();
|
||||
const chainL = chain.slice(0, length);
|
||||
for (let k = 0; k < 10 && (Date.now() - start < 10 * 1000 || k < 2); k++) {
|
||||
const s = performance.now();
|
||||
buffer = await createGif(grid, chainL, drawOptions, gifOptions);
|
||||
stats.push(performance.now() - s);
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
"---",
|
||||
`grid dimension: ${grid.width}x${grid.height}`,
|
||||
`chain length: ${length}`,
|
||||
`resulting size: ${(buffer!.length / 1024).toFixed(1)}ko`,
|
||||
`generation duration (mean): ${(
|
||||
stats.reduce((s, x) => x + s) / stats.length
|
||||
).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}ms`,
|
||||
"",
|
||||
].join("\n"),
|
||||
stats
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
`__tests__/__snapshots__/benchmark-output-${length}.gif`,
|
||||
buffer!
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -1,71 +1,42 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createGif } from "..";
|
||||
import * as grids from "@snk/types/__fixtures__/grid";
|
||||
import { snake3 as snake } from "@snk/types/__fixtures__/snake";
|
||||
import { createSnakeFromCells, nextSnake } from "@snk/types/snake";
|
||||
import { getBestRoute } from "@snk/solver/getBestRoute";
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
|
||||
jest.setTimeout(20 * 1000);
|
||||
|
||||
const upscale = 1;
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2 * upscale,
|
||||
sizeCell: 16 * upscale,
|
||||
sizeDot: 12 * upscale,
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gifOptions = { frameDuration: 200, step: 1 };
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
const dir = path.resolve(__dirname, "__snapshots__");
|
||||
const gifOptions = { delay: 200 };
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
} catch (err) {}
|
||||
it("should generate gif", async () => {
|
||||
const grid = generateGrid(14, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
|
||||
for (const key of [
|
||||
"empty",
|
||||
"simple",
|
||||
"corner",
|
||||
"small",
|
||||
"smallPacked",
|
||||
] as const)
|
||||
it(`should generate ${key} gif`, async () => {
|
||||
const grid = grids[key];
|
||||
const snake = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
const chain = [snake, ...getBestRoute(grid, snake)!];
|
||||
const commands = computeBestRun(grid, snake, gameOptions).slice(0, 9);
|
||||
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, key + ".gif"), gif);
|
||||
});
|
||||
|
||||
it(`should generate swipper`, async () => {
|
||||
const grid = grids.smallFull;
|
||||
let snk = createSnakeFromCells(
|
||||
Array.from({ length: 6 }, (_, i) => ({ x: i, y: -1 }))
|
||||
const gif = await createGif(
|
||||
grid,
|
||||
snake,
|
||||
commands,
|
||||
drawOptions,
|
||||
gameOptions,
|
||||
gifOptions
|
||||
);
|
||||
|
||||
const chain = [snk];
|
||||
for (let y = -1; y < grid.height; y++) {
|
||||
snk = nextSnake(snk, 0, 1);
|
||||
chain.push(snk);
|
||||
|
||||
for (let x = grid.width - 1; x--; ) {
|
||||
snk = nextSnake(snk, (y + 100) % 2 ? 1 : -1, 0);
|
||||
chain.push(snk);
|
||||
}
|
||||
}
|
||||
|
||||
const gif = await createGif(grid, chain, drawOptions, gifOptions);
|
||||
|
||||
expect(gif).toBeDefined();
|
||||
|
||||
fs.writeFileSync(path.resolve(dir, "swipper.gif"), gif);
|
||||
});
|
||||
|
||||
35
packages/gif-creator/__tests__/dev.ts
Normal file
35
packages/gif-creator/__tests__/dev.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createGif } from "..";
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
const gifOptions = { delay: 20 };
|
||||
|
||||
const grid = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
|
||||
const snake = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
const commands = computeBestRun(grid, snake, gameOptions);
|
||||
|
||||
createGif(grid, snake, commands, drawOptions, gameOptions, gifOptions).then(
|
||||
(buffer) => {
|
||||
process.stdout.write(buffer);
|
||||
}
|
||||
);
|
||||
@@ -1,94 +1,92 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { createCanvas } from "canvas";
|
||||
import { Grid, copyGrid, Color } from "@snk/types/grid";
|
||||
import { Snake } from "@snk/types/snake";
|
||||
import {
|
||||
Options,
|
||||
drawLerpWorld,
|
||||
getCanvasWorldSize,
|
||||
} from "@snk/draw/drawWorld";
|
||||
import { step } from "@snk/solver/step";
|
||||
import tmp from "tmp";
|
||||
import gifsicle from "gifsicle";
|
||||
import { Grid, copyGrid, Color } from "@snk/compute/grid";
|
||||
import { Point } from "@snk/compute/point";
|
||||
import { copySnake } from "@snk/compute/snake";
|
||||
import { drawWorld } from "@snk/draw/drawWorld";
|
||||
import { step } from "@snk/compute/step";
|
||||
import * as tmp from "tmp";
|
||||
// @ts-ignore
|
||||
import GIFEncoder from "gif-encoder-2";
|
||||
import * as execa from "execa";
|
||||
|
||||
export const createGif = async (
|
||||
grid0: Grid,
|
||||
snake0: Point[],
|
||||
commands: Point[],
|
||||
drawOptions: Parameters<typeof drawWorld>[4],
|
||||
gameOptions: Parameters<typeof step>[4],
|
||||
gifOptions: { delay: number }
|
||||
) => {
|
||||
const grid = copyGrid(grid0);
|
||||
const snake = copySnake(snake0);
|
||||
const stack: Color[] = [];
|
||||
|
||||
const width = drawOptions.sizeCell * (grid.width + 4);
|
||||
const height = drawOptions.sizeCell * (grid.height + 4) + 100;
|
||||
|
||||
const withTmpDir = async <T>(
|
||||
handler: (dir: string) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const { name: dir, removeCallback: cleanUp } = tmp.dirSync({
|
||||
unsafeCleanup: true,
|
||||
});
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const writeImage = (i: number) => {
|
||||
ctx.clearRect(0, 0, 99999, 99999);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, 99999, 99999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
|
||||
const buffer = canvas.toBuffer("image/png", {
|
||||
compressionLevel: 0,
|
||||
filters: canvas.PNG_FILTER_NONE,
|
||||
});
|
||||
|
||||
const fileName = path.join(dir, `${i.toString().padStart(4, "0")}.png`);
|
||||
|
||||
fs.writeFileSync(fileName, buffer);
|
||||
};
|
||||
|
||||
try {
|
||||
return await handler(dir);
|
||||
} finally {
|
||||
cleanUp();
|
||||
}
|
||||
};
|
||||
writeImage(0);
|
||||
|
||||
export const createGif = async (
|
||||
grid0: Grid,
|
||||
chain: Snake[],
|
||||
drawOptions: Options,
|
||||
gifOptions: { frameDuration: number; step: number }
|
||||
) =>
|
||||
withTmpDir(async (dir) => {
|
||||
const { width, height } = getCanvasWorldSize(grid0, drawOptions);
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const grid = copyGrid(grid0);
|
||||
const stack: Color[] = [];
|
||||
|
||||
const encoder = new GIFEncoder(width, height, "neuquant", true);
|
||||
encoder.setRepeat(0);
|
||||
encoder.setDelay(gifOptions.frameDuration);
|
||||
encoder.start();
|
||||
|
||||
for (let i = 0; i < chain.length; i += 1) {
|
||||
const snake0 = chain[i];
|
||||
const snake1 = chain[Math.min(chain.length - 1, i + 1)];
|
||||
step(grid, stack, snake0);
|
||||
|
||||
for (let k = 0; k < gifOptions.step; k++) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
drawLerpWorld(
|
||||
ctx,
|
||||
grid,
|
||||
snake0,
|
||||
snake1,
|
||||
stack,
|
||||
k / gifOptions.step,
|
||||
drawOptions
|
||||
);
|
||||
|
||||
encoder.addFrame(ctx);
|
||||
}
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
step(grid, snake, stack, commands[i], gameOptions);
|
||||
writeImage(i + 1);
|
||||
}
|
||||
|
||||
const outFileName = path.join(dir, "out.gif");
|
||||
const optimizedFileName = path.join(dir, "out.optimized.gif");
|
||||
|
||||
encoder.finish();
|
||||
fs.writeFileSync(outFileName, encoder.out.getData());
|
||||
await execa(
|
||||
"gm",
|
||||
[
|
||||
"convert",
|
||||
["-loop", "0"],
|
||||
["-delay", gifOptions.delay.toString()],
|
||||
["-dispose", "2"],
|
||||
// ["-layers", "OptimizeFrame"],
|
||||
["-compress", "LZW"],
|
||||
["-strip"],
|
||||
|
||||
execFileSync(
|
||||
gifsicle,
|
||||
path.join(dir, "*.png"),
|
||||
outFileName,
|
||||
].flat()
|
||||
);
|
||||
|
||||
await execa(
|
||||
"gifsicle",
|
||||
[
|
||||
//
|
||||
"--optimize=3",
|
||||
"--color-method=diversity",
|
||||
"--colors=18",
|
||||
outFileName,
|
||||
["--output", optimizedFileName],
|
||||
].flat()
|
||||
);
|
||||
|
||||
return fs.readFileSync(optimizedFileName);
|
||||
});
|
||||
} finally {
|
||||
cleanUp();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
"name": "@snk/gif-creator",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0",
|
||||
"@snk/draw": "1.0.0",
|
||||
"@snk/solver": "1.0.0",
|
||||
"canvas": "2.9.1",
|
||||
"gif-encoder-2": "1.0.5",
|
||||
"gifsicle": "5.3.0",
|
||||
"canvas": "2.6.1",
|
||||
"execa": "4.0.3",
|
||||
"tmp": "0.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/gifsicle": "5.2.0",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/execa": "2.0.0",
|
||||
"@types/tmp": "0.2.0",
|
||||
"@zeit/ncc": "0.22.3"
|
||||
},
|
||||
"scripts": {
|
||||
"benchmark": "ncc run __tests__/benchmark.ts --quiet"
|
||||
"dev": "ncc run __tests__/dev.ts --quiet | tail -n +2 > out.gif "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# @snk/github-user-contribution-service
|
||||
|
||||
Expose github-user-contribution as an endpoint, using vercel.sh
|
||||
@@ -1,16 +0,0 @@
|
||||
import { getGithubUserContribution } from "@snk/github-user-contribution";
|
||||
import { NowRequest, NowResponse } from "@vercel/node";
|
||||
|
||||
export default async (req: NowRequest, res: NowResponse) => {
|
||||
const { userName } = req.query;
|
||||
|
||||
try {
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://platane.github.io");
|
||||
res.statusCode = 200;
|
||||
res.json(await getGithubUserContribution(userName as string));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "@snk/github-user-contribution-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/github-user-contribution": "1.0.0",
|
||||
"@vercel/node": "1.14.0"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"github": {
|
||||
"silent": true
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
# @snk/github-user-contribution
|
||||
|
||||
Get the github user contribution graph
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
|
||||
// colorScheme = [
|
||||
// "#ebedf0",
|
||||
// "#9be9a8",
|
||||
// ...
|
||||
// ]
|
||||
// cells = [
|
||||
// {
|
||||
// x: 3,
|
||||
// y: 0,
|
||||
// count: 3,
|
||||
// color: '#ebedf0',
|
||||
// date:'2019-01-18'
|
||||
// },
|
||||
// ...
|
||||
// ]
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Based on the html page. Which is very unstable. We might switch to using github api but afaik it's a bit complex.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
import { formatParams } from "../formatParams";
|
||||
|
||||
const params = [
|
||||
//
|
||||
[{}, ""],
|
||||
[{ year: 2017 }, "from=2017-01-01&to=2017-12-31"],
|
||||
[{ from: "2017-12-03" }, "from=2017-12-03"],
|
||||
[{ to: "2017-12-03" }, "to=2017-12-03"],
|
||||
] as const;
|
||||
|
||||
params.forEach(([params, res]) =>
|
||||
it(`should format ${JSON.stringify(params)}`, () => {
|
||||
expect(formatParams(params)).toBe(res);
|
||||
})
|
||||
);
|
||||
|
||||
it("should fail if the date is in the future", () => {
|
||||
expect(() => formatParams({ to: "9999-01-01" })).toThrow(Error);
|
||||
});
|
||||
@@ -1,54 +1,14 @@
|
||||
import { getGithubUserContribution } from "..";
|
||||
|
||||
describe("getGithubUserContribution", () => {
|
||||
const promise = getGithubUserContribution("platane");
|
||||
it("should get user contribution", async () => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
|
||||
it("should resolve", async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("should get colorScheme", async () => {
|
||||
const { colorScheme } = await promise;
|
||||
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should get around 365 cells", async () => {
|
||||
const { cells } = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThanOrEqual(365);
|
||||
expect(cells.length).toBeLessThanOrEqual(365 + 7);
|
||||
});
|
||||
|
||||
it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => {
|
||||
const { cells, colorScheme } = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
|
||||
const undefinedDays = Array.from({ length: Math.floor(365 / 7) })
|
||||
.map((x) => Array.from({ length: 7 }).map((y) => ({ x, y })))
|
||||
.flat()
|
||||
.filter(({ x, y }) => cells.some((c) => c.x === x && c.y === y));
|
||||
|
||||
expect(undefinedDays).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should match snapshot for year=2019", async () => {
|
||||
expect(
|
||||
await getGithubUserContribution("platane", { year: 2019 })
|
||||
).toMatchSnapshot();
|
||||
expect(cells).toBeDefined();
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export type Options = { from?: string; to?: string } | { year: number };
|
||||
|
||||
export const formatParams = (options: Options = {}) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
const o: any = { ...options };
|
||||
|
||||
if ("year" in options) {
|
||||
o.from = `${options.year}-01-01`;
|
||||
o.to = `${options.year}-12-31`;
|
||||
}
|
||||
|
||||
for (const s of ["from", "to"])
|
||||
if (o[s]) {
|
||||
const value = o[s];
|
||||
|
||||
if (value >= formatDate(new Date()))
|
||||
throw new Error(
|
||||
"Cannot get contribution for a date in the future.\nPlease limit your range to the current UTC day."
|
||||
);
|
||||
|
||||
sp.set(s, value);
|
||||
}
|
||||
|
||||
return sp.toString();
|
||||
};
|
||||
|
||||
const formatDate = (d: Date) => {
|
||||
const year = d.getUTCFullYear();
|
||||
const month = d.getUTCMonth() + 1;
|
||||
const date = d.getUTCDate();
|
||||
|
||||
return [
|
||||
year,
|
||||
month.toString().padStart(2, "0"),
|
||||
date.toString().padStart(2, "0"),
|
||||
].join("-");
|
||||
};
|
||||
@@ -1,131 +1,61 @@
|
||||
import fetch from "node-fetch";
|
||||
import * as cheerio from "cheerio";
|
||||
import { formatParams, Options } from "./formatParams";
|
||||
// import * as https from "https";
|
||||
|
||||
/**
|
||||
* get the contribution grid from a github user page
|
||||
*
|
||||
* use options.from=YYYY-MM-DD options.to=YYYY-MM-DD to get the contribution grid for a specific time range
|
||||
* or year=2019 as an alias for from=2019-01-01 to=2019-12-31
|
||||
*
|
||||
* otherwise return use the time range from today minus one year to today ( as seen in github profile page )
|
||||
*
|
||||
* @param userName github user name
|
||||
* @param options
|
||||
*
|
||||
* @example
|
||||
* getGithubUserContribution("platane", { from: "2019-01-01", to: "2019-12-31" })
|
||||
* getGithubUserContribution("platane", { year: 2019 })
|
||||
*
|
||||
*/
|
||||
export const getGithubUserContribution = async (
|
||||
userName: string,
|
||||
options: Options = {}
|
||||
) => {
|
||||
// either use github.com/users/xxxx/contributions for previous years
|
||||
// or github.com/xxxx ( which gives the latest update to today result )
|
||||
const url =
|
||||
"year" in options || "from" in options || "to" in options
|
||||
? `https://github.com/users/${userName}/contributions?` +
|
||||
formatParams(options)
|
||||
: `https://github.com/${userName}`;
|
||||
// @ts-ignore
|
||||
// import * as cheerio from "cheerio";
|
||||
|
||||
const res = await fetch(url);
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
export const getGithubUserContribution = async (userName: string) => {
|
||||
// const content: string = await new Promise((resolve, reject) => {
|
||||
// const req = https.request(`https://github.com/${userName}`, (res) => {
|
||||
// let data = "";
|
||||
|
||||
const resText = await res.text();
|
||||
// res.on("error", reject);
|
||||
// res.on("data", (chunk) => (data += chunk));
|
||||
// res.on("end", () => resolve(data));
|
||||
// });
|
||||
|
||||
return parseUserPage(resText);
|
||||
};
|
||||
// req.on("error", reject);
|
||||
// req.end();
|
||||
// });
|
||||
|
||||
const defaultColorScheme = [
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
];
|
||||
// const dom = new JSDOM(content);
|
||||
|
||||
const parseUserPage = (content: string) => {
|
||||
const $ = cheerio.load(content);
|
||||
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
|
||||
|
||||
//
|
||||
// "parse" colorScheme
|
||||
const colorScheme = [...defaultColorScheme];
|
||||
const colorScheme = Array.from(
|
||||
dom.window.document.querySelectorAll(".legend > li")
|
||||
).map(
|
||||
(element) =>
|
||||
element.getAttribute("style")?.match(/background\-color: +(#\w+)/)?.[1]!
|
||||
);
|
||||
|
||||
//
|
||||
// parse cells
|
||||
const rawCells = $(".js-calendar-graph rect[data-count]")
|
||||
.toArray()
|
||||
.map((x) => {
|
||||
const level = +x.attribs["data-level"];
|
||||
const count = +x.attribs["data-count"];
|
||||
const date = x.attribs["data-date"];
|
||||
const cells = Array.from(
|
||||
dom.window.document.querySelectorAll(".js-calendar-graph-svg > g > g")
|
||||
)
|
||||
.map((column, x) =>
|
||||
Array.from(column.querySelectorAll("rect")).map((element, y) => ({
|
||||
x,
|
||||
y,
|
||||
count: element.getAttribute("data-count"),
|
||||
date: element.getAttribute("data-date"),
|
||||
color: element.getAttribute("fill"),
|
||||
k: colorScheme.indexOf(element.getAttribute("fill")!),
|
||||
}))
|
||||
)
|
||||
.flat();
|
||||
|
||||
const color = colorScheme[level];
|
||||
|
||||
if (!color) throw new Error("could not determine the color of the cell");
|
||||
|
||||
return {
|
||||
svgPosition: getSvgPosition(x),
|
||||
color,
|
||||
count,
|
||||
date,
|
||||
};
|
||||
});
|
||||
|
||||
const xMap: Record<number, true> = {};
|
||||
const yMap: Record<number, true> = {};
|
||||
rawCells.forEach(({ svgPosition: { x, y } }) => {
|
||||
xMap[x] = true;
|
||||
yMap[y] = true;
|
||||
});
|
||||
|
||||
const xRange = Object.keys(xMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
const yRange = Object.keys(yMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
|
||||
const cells = rawCells.map(({ svgPosition, ...c }) => ({
|
||||
...c,
|
||||
x: xRange.indexOf(svgPosition.x),
|
||||
y: yRange.indexOf(svgPosition.y),
|
||||
}));
|
||||
|
||||
return { cells, colorScheme };
|
||||
};
|
||||
|
||||
// returns the position of the svg elements, accounting for it's transform and it's parent transform
|
||||
// ( only accounts for translate transform )
|
||||
const getSvgPosition = (
|
||||
e: cheerio.Element | null
|
||||
): { x: number; y: number } => {
|
||||
if (!e || e.tagName === "svg") return { x: 0, y: 0 };
|
||||
|
||||
const p = getSvgPosition(e.parent as cheerio.Element);
|
||||
|
||||
if (e.attribs.x) p.x += +e.attribs.x;
|
||||
if (e.attribs.y) p.y += +e.attribs.y;
|
||||
|
||||
if (e.attribs.transform) {
|
||||
const m = e.attribs.transform.match(
|
||||
/translate\( *([\.\d]+) *, *([\.\d]+) *\)/
|
||||
);
|
||||
|
||||
if (m) {
|
||||
p.x += +m[1];
|
||||
p.y += +m[2];
|
||||
}
|
||||
}
|
||||
|
||||
return p;
|
||||
return { colorScheme, cells };
|
||||
};
|
||||
|
||||
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
|
||||
|
||||
export type Res = ThenArg<ReturnType<typeof getGithubUserContribution>>;
|
||||
export type Cell = ThenArg<
|
||||
ReturnType<typeof getGithubUserContribution>
|
||||
>["cells"][number];
|
||||
|
||||
export type Cell = Res["cells"][number];
|
||||
// "#ebedf0";
|
||||
// "#9be9a8";
|
||||
// "#40c463";
|
||||
// "#30a14e";
|
||||
// "#216e39";
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
"name": "@snk/github-user-contribution",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"cheerio": "1.0.0-rc.10",
|
||||
"node-fetch": "2.6.1"
|
||||
"jsdom": "16.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-fetch": "2.6.1"
|
||||
"@types/jsdom": "16.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# @snk/solver
|
||||
|
||||
Contains the algorithm to compute the best route given a grid and a starting position for the snake.
|
||||
|
||||
## Implementation
|
||||
|
||||
- for each color in the grid
|
||||
|
||||
- 1\ **clear residual color** phase
|
||||
|
||||
- find all the cells of a previous color that are "tunnel-able" ( ie: the snake can find a path from the outside of the grid to the cell, and can go back to the outside without colliding ). The snake is allowed to pass thought current and previous color. Higher colors are walls
|
||||
|
||||
- sort the "tunnel-able" cell, there is penalty for passing through current color, as previous color should be eliminated as soon as possible.
|
||||
|
||||
- for cells with the same score, take the closest one ( determined with a quick mathematic distance, which is not accurate but fast at least )
|
||||
|
||||
- navigate to the cell, and through the tunnel.
|
||||
|
||||
- re-compute the list of tunnel-able cells ( as eating cells might have freed better tunnel ) as well as the score
|
||||
|
||||
- iterate
|
||||
|
||||
- 2\ **clear clean color** phase
|
||||
|
||||
- find all the cells of the current color that are "tunnel-able"
|
||||
|
||||
- no need to consider scoring here. In order to improve efficiency, get the closest cell by doing a tree search ( instead of a simple mathematic distance like in the previous phase )
|
||||
|
||||
- navigate to the cell, and through the tunnel.
|
||||
|
||||
- iterate
|
||||
|
||||
- go back to the starting point
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user