mirror of
https://github.com/ekrizdis367/obsidian-caissa.git
synced 2026-07-22 06:50:08 +00:00
Initial commit: Caissa Obsidian plugin
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
c6bd0cf8ff
56 changed files with 16239 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
28
.github/workflows/lint.yml
vendored
Normal file
28
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- To use eslint to analyze this project use this command: `eslint main.ts`
|
||||
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
|
||||
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
|
||||
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
|
||||
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
536
README.md
Normal file
536
README.md
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
# Caissa
|
||||
|
||||
A chess study plugin for [Obsidian](https://obsidian.md): embed positions, study openings and endgames, browse World Championship games, and annotate PGNs inside your notes. Named after [Caïssa](https://en.wikipedia.org/wiki/Caïssa), the muse of chess.
|
||||
|
||||
- Pick from a built-in library of openings and variations (Sicilian → Dragon, Najdorf, Yugoslav…)
|
||||
- Play any position **against Stockfish** with adjustable strength (level 0–20) — fully offline, click-to-move, with promotion/undo/new-game controls
|
||||
- Study **canonical endgame techniques** — basic mates, Lucena, Philidor, Vancura, opposition, wrong-bishop draw, and more
|
||||
- Browse **bundled World Championship games** — 68 hand-picked decisive/famous games from 1886 (Steinitz–Zukertort) through 2024 (Gukesh–Ding)
|
||||
- Paste a **PGN from anywhere** (Lichess, chess.com, ChessBase, your own play) and play through it with full headers
|
||||
- Pull a Lichess game directly with `lichess: <url>` — no token needed
|
||||
- Step through moves with `prev`/`next` controls or click any move in the list
|
||||
- Drop multiple boards in the same note to compare variations side by side
|
||||
- Two-column white/black move list right next to the board
|
||||
- 7 piece styles to choose from — classic, modern, retro, and more (see [Piece sets](#piece-sets))
|
||||
- Optional **Opening Explorer** panel showing win/draw/loss percentages for every candidate next move (powered by [Lichess](https://lichess.org), opt-in)
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Usage](#usage) — [Inline picker](#inline-picker), [Sizing and layout](#sizing-and-layout), [Loading whole games](#loading-whole-games), [Built-in openings](#built-in-openings), [Endgames](#endgames), [World Championship games](#world-championship-games), [Piece sets](#piece-sets)
|
||||
- [Commands](#commands)
|
||||
- [Export a board as PNG or SVG](#export-a-board-as-png-or-svg)
|
||||
- [Captured pieces and material balance](#captured-pieces-and-material-balance)
|
||||
- [Annotations: arrows and square highlights](#annotations-arrows-and-square-highlights)
|
||||
- [Keyboard shortcuts](#keyboard-shortcuts)
|
||||
- [Engine analysis (Stockfish, offline)](#engine-analysis-stockfish-offline)
|
||||
- [Play against Stockfish](#play-against-stockfish)
|
||||
- [Opening Explorer (win / draw / loss bars)](#opening-explorer-win--draw--loss-bars)
|
||||
- [Block options reference](#block-options-reference) — every key you can put in a `chess` block
|
||||
- [Settings](#settings)
|
||||
- [Development](#development)
|
||||
- [Credits](#credits)
|
||||
|
||||
## Quick start
|
||||
|
||||
If you only read three examples, read these. Each is a complete, self-contained block — paste it into any note and it just works.
|
||||
|
||||
**1. Pull up a known opening:**
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Dragon
|
||||
```
|
||||
````
|
||||
|
||||
**2. Drop in a PGN you copied from anywhere:**
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
[White "Carlsen, Magnus"]
|
||||
[Black "Nakamura, Hikaru"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nf6 3. Nxe5 d6 4. Nf3 Nxe4 ...
|
||||
```
|
||||
````
|
||||
|
||||
**3. Play a real game against Stockfish from any starting position:**
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
opening: Italian Game
|
||||
play: white
|
||||
level: 5
|
||||
```
|
||||
````
|
||||
|
||||
That's the whole mental model: every block is a starting position plus optional behavior. See [Block options reference](#block-options-reference) for every key you can use, or keep reading for guided examples.
|
||||
|
||||
## Usage
|
||||
|
||||
Add a fenced code block with the language `chess`:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Dragon
|
||||
```
|
||||
````
|
||||
|
||||
That renders the full Dragon line with a board, an interactive move list, and step controls. Anything you can put in a code block is supported, so you can have several variations stacked in the same note:
|
||||
|
||||
````markdown
|
||||
## Sicilian Defense
|
||||
|
||||
### Najdorf
|
||||
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Najdorf
|
||||
```
|
||||
|
||||
### Dragon
|
||||
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Dragon
|
||||
title: My Dragon notes
|
||||
```
|
||||
|
||||
### Yugoslav Attack against the Dragon
|
||||
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Yugoslav Attack
|
||||
```
|
||||
````
|
||||
|
||||
### Inline picker
|
||||
|
||||
Don't want to look up slugs? Drop a blank `chess` block — either the **Insert chess board** command or just type the fences yourself:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
```
|
||||
````
|
||||
|
||||
The rendered block grows a little dropdown strip across the top: pick a category (Openings, Endgames, World Championship), then a specific opening/variation/endgame/game from a second dropdown. Your selection is written back into the block's source as `opening: …` / `variation: …` / `endgame: …` / `wccgame: …`, so the result is the same as if you'd typed it by hand — and it survives reload, sync, and copy-paste.
|
||||
|
||||
This is the friendliest way to use the plugin if you don't already have a position in mind.
|
||||
|
||||
> See [Block options reference](#block-options-reference) for every key you can put in a `chess` block.
|
||||
|
||||
### Sizing and layout
|
||||
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Dragon
|
||||
size: large # small | medium (default) | large | full
|
||||
showMoves: true # set false to hide the right-hand move list
|
||||
interactive: false # set false to hide the prev/next/flip buttons
|
||||
```
|
||||
|
||||
- `small` ≈ 240px board, `medium` ≈ 360px (default), `large` ≈ 500px.
|
||||
- `size: full` makes the board fill the writing area's width and pushes the move list below it.
|
||||
- The board's column also auto-collapses to a single stacked layout when the writing pane is narrower than ~520px (mobile screens, narrow split-pane editing). This uses CSS container queries so it responds to the *pane* width, not the whole window.
|
||||
- `showMoves: false` lets the board take the row by itself without an empty side column.
|
||||
|
||||
If both `opening` and `moves` are provided, `moves` wins (handy for adding extra moves past the book). If `pgn` or `lichess` is set, those win over everything else (they bring their own moves and headers).
|
||||
|
||||
#### Pick the position to show on first render
|
||||
|
||||
By default a block opens at the starting position (move 0) so you can step through. If you'd rather land on a specific move — to highlight a critical position, jump straight to a tactic, or open a long PGN at the interesting middlegame — use `startMove`:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Najdorf
|
||||
startMove: 12
|
||||
```
|
||||
````
|
||||
|
||||
`startMove` is a 0-indexed step into the move list (`0` = starting position, `1` = after White's first move, `2` = after Black's first reply, and so on). Out-of-range values clamp to the last move, so `startMove: 999` on a 30-move game just opens at the end. Stepping with `←`/`→` works normally from there — it's purely the initial render position, not a hard limit.
|
||||
|
||||
### Loading whole games
|
||||
|
||||
#### Paste a PGN
|
||||
|
||||
If the entire body of the block looks like a PGN — that is, it starts with a `[Tag "value"]` header, or contains move tokens with no `key: value` lines — the plugin treats the whole block as a PGN. No `pgn:` prefix needed:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
[Event "Norway Chess"]
|
||||
[Site "Stavanger NOR"]
|
||||
[Date "2024.05.27"]
|
||||
[White "Carlsen, Magnus"]
|
||||
[Black "Nakamura, Hikaru"]
|
||||
[Result "1-0"]
|
||||
[WhiteElo "2830"]
|
||||
[BlackElo "2789"]
|
||||
[ECO "C42"]
|
||||
[Opening "Russian Game"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nf6 3. Nxe5 d6 4. Nf3 Nxe4 5. d4 d5 6. Bd3 Bd6 ...
|
||||
```
|
||||
````
|
||||
|
||||
The plugin renders a clean game-info strip above the board (players · result · event · date · ECO/opening), strips any `{ [%eval ...] [%clk ...] }` annotations, and walks the mainline. Variations and comments are dropped.
|
||||
|
||||
PGNs from Lichess, chess.com (Share → PGN), ChessBase, SCID, and the Lichess `pgn-extract` tool all paste cleanly.
|
||||
|
||||
#### Pull a Lichess game by URL
|
||||
|
||||
If you'd rather just point at a Lichess game, use the `lichess` key:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
lichess: https://lichess.org/q7ZvsdUF
|
||||
```
|
||||
````
|
||||
|
||||
A bare game ID also works (`lichess: q7ZvsdUF`). The plugin fetches the PGN from `https://lichess.org/game/export/<id>` over HTTPS — **no token required for this endpoint**, unlike the Opening Explorer. Results are cached in memory for the session.
|
||||
|
||||
You can combine `lichess:` with sizing/orientation keys:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
lichess: https://lichess.org/q7ZvsdUF
|
||||
size: full
|
||||
orientation: black
|
||||
```
|
||||
````
|
||||
|
||||
### Built-in openings
|
||||
|
||||
Italian Game · Ruy Lopez · Sicilian Defense (Najdorf, Dragon, Yugoslav, Accelerated Dragon, Scheveningen, Sveshnikov, Taimanov, Kan, Classical, Closed, Alapin, Smith-Morra) · French Defense · Caro-Kann · Scandinavian · Pirc · Alekhine's · Queen's Gambit (QGA, QGD, Slav, Semi-Slav, Tarrasch, Albin) · King's Indian · Nimzo-Indian · Grünfeld · English · Réti · London System.
|
||||
|
||||
Use the **Insert chess opening from library** command (Cmd/Ctrl+P) to fuzzy-search and insert a block for any of them.
|
||||
|
||||
### Endgames
|
||||
|
||||
The plugin ships ten canonical endgame techniques. Use them with `endgame: <slug>`:
|
||||
|
||||
| Slug | Topic | Demo line? |
|
||||
| --- | --- | --- |
|
||||
| `kqk-mate` | King + queen vs king (basic queen mate) | full technique, mate in 8 |
|
||||
| `krk-mate` | King + rook vs king (basic rook mate) | full technique, mate in 4 |
|
||||
| `krrk-ladder-mate` | Two rooks vs king (ladder mate) | mate in 2 |
|
||||
| `kbnk-mate` | Bishop + knight mate (the W-manoeuvre) | Karttunen vs Rasik 2003, mate in 23 |
|
||||
| `kbbk-mate` | Two bishops mate | Leslie-Hurd 2005 tablebase, mate in 19 |
|
||||
| `lucena` | Lucena position — winning the rook endgame with the bridge | study position |
|
||||
| `philidor` | Philidor position — drawing the rook endgame | study position |
|
||||
| `vancura` | Vancura position — rook + a-pawn draw | study position |
|
||||
| `kpk-opposition` | King + pawn vs king — opposition and key squares | study position |
|
||||
| `wrong-bishop` | Wrong-colored bishop + rook-pawn draw | study position |
|
||||
|
||||
All five mating endgames ship a verified mating sequence you can step through with **next**. KQK, KRK, and the two-rook ladder use hand-crafted demo lines from a central starting position. For the two hardest mates we ship real games that were actually played out: **KBNK** uses the ending of Karttunen vs Rasik (European Club Cup 2003), picked up at move 84 with 23 moves of W-manoeuvre to mate; **KBBK** uses Joe Leslie-Hurd's 2005 tablebase analysis of the longest forced KBBK mate, with both sides playing perfectly to mate-in-19. The five non-mating positions (Lucena, Philidor, Vancura, K+P opposition, wrong-bishop) are study positions where the technique itself depends on best play from both sides, so the plugin lets you explore them without forcing a fixed line.
|
||||
|
||||
Example:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
endgame: lucena
|
||||
```
|
||||
````
|
||||
|
||||
Use the **Insert chess endgame from library** command for a fuzzy picker.
|
||||
|
||||
### World Championship games
|
||||
|
||||
The plugin ships 68 hand-picked World Championship games covering 20 matches from 1886 (Steinitz–Zukertort) through 2024 (Gukesh–Ding). Each is referenced by a stable slug formatted `<year>-<player1>-<player2>-<game>`:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
wccgame: 1972-fischer-spassky-06
|
||||
```
|
||||
````
|
||||
|
||||
That renders Fischer's famous game 6 against Spassky from Reykjavik with full headers, the result, the ECO code, and a clickable move list.
|
||||
|
||||
Use the **Insert chess world championship game** command for a two-step picker (match → game). PGNs are sourced from [PGN Mentor](https://pgnmentor.com/) and bundled inline with the plugin — no network calls at runtime.
|
||||
|
||||
To curate or extend the bundled set, edit `scripts/wcc-curated.json` and re-run `node scripts/fetch-wcc-games.mjs`.
|
||||
|
||||
### Piece sets
|
||||
|
||||
Set the global default in **Settings → Community plugins → Caissa → Piece set**, or override per board with `pieces: <name>`.
|
||||
|
||||
| Name | Style |
|
||||
| --- | --- |
|
||||
| `cburnett` | Classic Lichess look (default) — neutral, recognizable |
|
||||
| `merida` | Traditional chess-club style, slightly heavier than cburnett |
|
||||
| `staunty` | Modern bold Staunton tournament look |
|
||||
| `caliente` | Warm modern Staunton with subtle gradient shading |
|
||||
| `pixel` | 8-bit retro pixel art |
|
||||
| `letter` | Just K Q R B N P text — high contrast, accessibility-friendly |
|
||||
| `unicode` | Unicode chess characters from your system font |
|
||||
|
||||
## Commands
|
||||
|
||||
- **Insert chess board** — drops an empty `chess` code block at the cursor.
|
||||
- **Insert chess opening from library** — fuzzy-search modal for openings and variations.
|
||||
- **Insert chess endgame from library** — fuzzy-search the bundled endgame techniques.
|
||||
- **Insert chess world championship game** — two-step picker (match → game) over the bundled WCC corpus.
|
||||
- **Insert chess board from clipboard fen** — reads a FEN string from the clipboard and seeds a block.
|
||||
- **Insert chess game from clipboard pgn** — reads PGN text from the clipboard and drops it into a `chess` block.
|
||||
- **Insert chess game from lichess link** — reads a Lichess game URL or ID from the clipboard and inserts a `lichess: <id>` block.
|
||||
- **Start openings quiz** — opens a modal that endlessly serves up positions from the bundled opening repertoire and asks for the next book move. Move spelling variants (`Nf3` vs `Ngf3`) are normalized via chess.js so anything legal counts. Tracks correct/total and a streak per session.
|
||||
|
||||
Or, drop a blank `chess` block (the **Insert chess board** command) and use the inline picker — a category dropdown lets you switch between Openings, Endgames, and World Championship right inside the rendered block, without typing slugs.
|
||||
|
||||
## Export a board as PNG or SVG
|
||||
|
||||
Right-click any rendered board (long-press on touch) for a context menu with:
|
||||
|
||||
- **Copy as image** — places a PNG on the system clipboard, ready to paste into Discord, Twitter, Obsidian notes, etc.
|
||||
- **Copy as SVG** — places the SVG markup on the clipboard.
|
||||
- **Save as PNG** — downloads a `caissa-board-<position>.png` file at 2× resolution (720×720 by default).
|
||||
- **Save as SVG** — downloads a `caissa-board-<position>.svg` file.
|
||||
|
||||
Exports always reflect the *current* position on screen (after stepping moves or flipping), and use whichever piece set / colors / annotations you've configured for the block — what you see is what you copy.
|
||||
|
||||
## Captured pieces and material balance
|
||||
|
||||
Every board automatically shows a captured-pieces tray above and below it, plus a `+N` material badge next to the side that's ahead. The piece glyphs use whichever piece set is active for the board so the tray always matches.
|
||||
|
||||
- Pieces captured by White appear on White's side (bottom of the board by default; top if you flip with `F`).
|
||||
- Pieces captured by Black appear on Black's side.
|
||||
- The `+N` badge shows pawn-equivalent material — promotions are accounted for, so a queened pawn shows as `+8` rather than `+1` once it promotes.
|
||||
- Trays auto-hide on positions where nothing has been captured (a fresh starting position, an endgame study with no captures), so they never add empty space.
|
||||
|
||||
You can hide the trays globally in **Settings → Caissa → Show captured pieces**, or per-block with `captured: false`.
|
||||
|
||||
## Annotations: arrows and square highlights
|
||||
|
||||
Annotate any board with arrows (move suggestions, threats, plans) and square highlights (key squares, weak squares, mating nets). Both are declarative — no clicks involved — so they survive note round-trips and look the same on every device.
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
fen: r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3
|
||||
arrows: f3e5 g1f3-blue
|
||||
highlights: e5 f7-red
|
||||
```
|
||||
````
|
||||
|
||||
Token grammar:
|
||||
|
||||
- `arrows: <from><to>[-color]` — list of square pairs, whitespace-separated. `e2e4 g1f3 d2d4-blue`.
|
||||
- `highlights: <square>[-color]` — list of squares, whitespace-separated. `d4 e4-red f5-yellow`.
|
||||
|
||||
Color presets: `green` (default), `red`, `yellow`, `blue`. You can also pass any 3, 6, or 8-character hex code without the `#` (e.g. `e2e4-ff5500`). Unknown colors silently fall back to green so a typo never breaks the board.
|
||||
|
||||
Highlights render as a colored ring inside the square (so the piece glyph stays readable); arrows render as a thick filled triangle on top of the board, matching the convention from Lichess and chess.com analysis boards.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Click any rendered chess block (or Tab into it — a soft accent ring shows it's focused) and use:
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `←` / `Page Up` | Previous move |
|
||||
| `→` / `Page Down` | Next move |
|
||||
| `Home` | Jump to the starting position |
|
||||
| `End` | Jump to the final position |
|
||||
| `F` | Flip the board |
|
||||
|
||||
Shortcuts are scoped to the focused block, so multiple boards on the same page never fight for keys, and typing in any text input still works normally.
|
||||
|
||||
## Engine analysis (Stockfish, offline)
|
||||
|
||||
Caissa ships a bundled offline copy of [Stockfish 10](https://stockfishchess.org/) (the asm.js build by Niklas Fiekas, ~1.5 MB), so you can run engine analysis with **zero network calls** and on a plane. The engine is lazy-loaded on first use — if you never set `analyze: true`, no worker is spawned and no extra memory is allocated.
|
||||
|
||||
Add `analyze: true` to any chess block to turn on the analysis panel:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
opening: Sicilian Defense
|
||||
variation: Najdorf
|
||||
analyze: true
|
||||
```
|
||||
````
|
||||
|
||||
That gives you:
|
||||
|
||||
- An **evaluation bar** to the left of the engine panel, showing the position from White's perspective (clamped at ±5 pawns for the bar; the numeric label is exact).
|
||||
- The **top 3 principal variations** — eval, search depth, and the move sequence Stockfish recommends.
|
||||
- A **whole-game eval graph** under the board with an **Analyze game** button. Click it once to evaluate every position in the loaded PGN at depth 12; the resulting sparkline shows where evaluation swings happened. Click anywhere on the graph to jump the board to that position.
|
||||
|
||||
Notes and tradeoffs:
|
||||
|
||||
- The engine is the pure-JS asm.js Stockfish 10 (no NNUE, no WebAssembly). Strength is around 3000+ Elo at depth 16 — more than enough for spotting tactics, blunders, and trends. If you need NNUE/cloud-grade analysis, run an analysis on Lichess and paste the PGN back in.
|
||||
- Each block runs analysis serially through one shared worker; the worker stays alive across blocks so subsequent positions analyze fast.
|
||||
- "Analyze game" on a 60-move game at depth 12 takes roughly 30–90 seconds depending on hardware.
|
||||
|
||||
The engine is bundled directly into `main.js`. Bundle size impact: the plugin grows from ~250 KB to ~1.8 MB. There are no extra files to install, no network downloads, and no telemetry.
|
||||
|
||||
## Play against Stockfish
|
||||
|
||||
Set up any position you'd render normally (start position, an opening, an endgame, a custom FEN), then add `play: white` (or `black`, or `random`) to start a game from that exact position against the bundled Stockfish:
|
||||
|
||||
````markdown
|
||||
```chess
|
||||
opening: Italian Game
|
||||
play: white
|
||||
level: 5
|
||||
```
|
||||
````
|
||||
|
||||
How it works:
|
||||
|
||||
- **Click to move.** Click a friendly piece to select it (legal targets light up as dots; capture squares get rings); click a destination square to play. Clicking the same square again deselects.
|
||||
- **Promotions** trigger a popup with Queen / Rook / Bishop / Knight choices.
|
||||
- **Game over** is detected automatically — checkmate, stalemate, threefold repetition, insufficient material, or 50-move rule. The status line below the board updates in place.
|
||||
- **Undo** rolls back to the last position where it was your turn (your move + the engine's reply).
|
||||
- **New game** resets to the configured starting position. If you set `play: random` the controller picks a side at mount time and a fresh "new game" keeps that side.
|
||||
- The captured-pieces tray + material badge work as usual so you can see how a game is going at a glance.
|
||||
|
||||
Strength control via `level: 0–20`:
|
||||
|
||||
| Level | Approx. Elo | Feels like |
|
||||
| --- | --- | --- |
|
||||
| 0 | ~1100 | Beginner — frequent blunders |
|
||||
| 4 | ~1500 | Casual club player |
|
||||
| 8 (default) | ~1700 | Decent club player |
|
||||
| 12 | ~1900 | Strong club player |
|
||||
| 16 | ~2300 | Expert |
|
||||
| 20 | ~3000+ | Full strength (depth-limited at 14) |
|
||||
|
||||
Lower levels also use shallower searches so weak moves come back fast — at `level: 0` the engine plays nearly instantly. At `level: 20` each move can take a couple seconds depending on hardware.
|
||||
|
||||
Notes:
|
||||
|
||||
- The board, captured tray, piece set, colors, and orientation all respect the same `pieces` / `light` / `dark` / `coordinates` config you'd use on a normal block.
|
||||
- `play` is mutually exclusive with stepping through a fixed PGN — once you set `play`, the move list becomes a passive transcript and the prev/next/last buttons are gone.
|
||||
- `play` plus `lichess: <url>` isn't supported (the lichess loader runs first); paste the PGN inline if you want to play a position from a Lichess game.
|
||||
|
||||
## Opening Explorer (win / draw / loss bars)
|
||||
|
||||
Optionally show a **Lines** panel under each board with the win/draw/loss percentage for every candidate next move at the current position, exactly like Lichess's analysis page.
|
||||
|
||||
### One-time setup
|
||||
|
||||
1. **Settings → Community plugins → Caissa → Lichess API token** — paste a token here.
|
||||
2. To create a token, go to [lichess.org/account/oauth/token](https://lichess.org/account/oauth/token), click **Create new personal access token**, give it any name (no scopes needed), generate it, and copy the token.
|
||||
3. Toggle on **Enable opening explorer**.
|
||||
|
||||
That's it. The panel now appears under every chess board and updates as you step through moves.
|
||||
|
||||
### Why a token?
|
||||
|
||||
Lichess started requiring authentication on its Opening Explorer API in March 2026 to mitigate DDoS abuse. The token is free, takes 30 seconds to create, and is rate-limited to 25 requests / minute (more than enough for normal study). Your token is stored locally in the plugin's settings file inside your vault — it is never sent anywhere except to Lichess.
|
||||
|
||||
### Data sources
|
||||
|
||||
- **Masters database** — over-the-board games played by 2200+ rated players (high-quality opening theory).
|
||||
- **Lichess players** — a much larger database covering all rated games on Lichess (good for offbeat lines).
|
||||
|
||||
You can switch sources globally in settings, or per board with `explorerSource: lichess`.
|
||||
|
||||
### Privacy
|
||||
|
||||
When the explorer is enabled, the plugin sends the current board position (FEN) and your Lichess API token to `https://explorer.lichess.ovh` over HTTPS. Nothing else is transmitted; your notes, vault contents, and other settings stay local. The explorer is **off by default** — you have to opt in.
|
||||
|
||||
When you use the `lichess: <id>` key, the plugin makes a separate, anonymous HTTPS request to `https://lichess.org/game/export/<id>` to download the PGN of that one specific game. No token is sent and no other request is made. This only happens for blocks that explicitly use the `lichess:` key.
|
||||
|
||||
Results are cached in memory for the session, so stepping back and forth through a line never refetches.
|
||||
|
||||
## Block options reference
|
||||
|
||||
Every key you can put inside a `chess` code block. All keys are optional and may appear in any order. Per-block keys always override the matching global setting.
|
||||
|
||||
### Position (pick one — the first one set wins)
|
||||
|
||||
| Key | Description | Example |
|
||||
| --- | --- | --- |
|
||||
| `opening` | Name from the bundled library — see [Built-in openings](#built-in-openings) | `opening: Italian Game` |
|
||||
| `variation` | Variation belonging to the opening above | `variation: Two Knights Defense` |
|
||||
| `endgame` | Endgame slug or display name — see [Endgames](#endgames) | `endgame: lucena` |
|
||||
| `wccgame` | World Championship game slug — see [World Championship games](#world-championship-games) | `wccgame: 1972-fischer-spassky-06` |
|
||||
| `fen` | Custom starting position (FEN). Use alone for puzzles or middlegames | `fen: rnbqkbnr/...` |
|
||||
| `pgn` | Single-line PGN. For multi-line PGNs, paste the PGN as the entire block body | `pgn: 1. e4 e5 2. Nf3` |
|
||||
| `lichess` | Lichess game ID or URL — fetches the full PGN over HTTPS, no token needed | `lichess: https://lichess.org/q7ZvsdUF` |
|
||||
| `moves` | Explicit SAN move list. Move numbers like `1.` are stripped. Combines with `opening` to extend a book line | `moves: e4 e5 Nf3 Nc6 Bc4` |
|
||||
|
||||
### Display
|
||||
|
||||
| Key | Description | Example |
|
||||
| --- | --- | --- |
|
||||
| `title` | Caption shown above the board | `title: My Dragon line` |
|
||||
| `orientation` | `white` or `black` | `orientation: black` |
|
||||
| `pieces` | One of `cburnett`, `merida`, `staunty`, `caliente`, `pixel`, `letter`, `unicode` | `pieces: caliente` |
|
||||
| `light` / `dark` | CSS color for board squares | `light: #eeeed2` |
|
||||
| `size` | `small`, `medium` (default), `large`, or `full` | `size: full` |
|
||||
| `coordinates` | `true`/`false` to toggle file/rank labels | `coordinates: false` |
|
||||
| `interactive` | `true`/`false` to toggle the prev/next/flip buttons | `interactive: false` |
|
||||
| `showMoves` | `true`/`false` to show or hide the right-hand move list | `showMoves: false` |
|
||||
| `startMove` | Index of the step to show on first render (0 = start, clamped to game length) — see [Sizing and layout](#sizing-and-layout) | `startMove: 12` |
|
||||
| `captured` | `true`/`false` to override **Show captured pieces** for this block | `captured: false` |
|
||||
|
||||
### Annotations
|
||||
|
||||
| Key | Description | Example |
|
||||
| --- | --- | --- |
|
||||
| `arrows` | Whitespace-separated `<from><to>[-color]` tokens — see [Annotations](#annotations-arrows-and-square-highlights) | `arrows: e2e4 g1f3-blue` |
|
||||
| `highlights` | Whitespace-separated `<square>[-color]` tokens | `highlights: d4 e4-red` |
|
||||
|
||||
### Engine
|
||||
|
||||
| Key | Description | Example |
|
||||
| --- | --- | --- |
|
||||
| `analyze` | `true` to lazy-load Stockfish and show eval bar + top engine lines + analyze-game graph — see [Engine analysis](#engine-analysis-stockfish-offline) | `analyze: true` |
|
||||
| `play` | `white`, `black`, or `random` — play that color against Stockfish from the configured position. See [Play against Stockfish](#play-against-stockfish) | `play: white` |
|
||||
| `level` | Stockfish skill level 0–20 (default 8). Lower = weaker / more human | `level: 4` |
|
||||
|
||||
### Opening Explorer
|
||||
|
||||
| Key | Description | Example |
|
||||
| --- | --- | --- |
|
||||
| `explorer` | `true`/`false` to enable the lines panel for this block only | `explorer: true` |
|
||||
| `explorerSource` | `masters` or `lichess` for this block only | `explorerSource: lichess` |
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings → Community plugins → Caissa** lets you customize:
|
||||
|
||||
- Default piece set (7 styles to choose from — see [Piece sets](#piece-sets))
|
||||
- Light / dark square colors
|
||||
- Last-move highlight color
|
||||
- Whether to show coordinates and interactive controls by default
|
||||
- Default board orientation
|
||||
- Opening Explorer enable / disable, data source, max lines, Lichess API token
|
||||
|
||||
Per-block settings always override the global defaults.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # watch + rebuild main.js
|
||||
npm run build # type-check and produce a production main.js
|
||||
npm run lint # eslint over src/
|
||||
```
|
||||
|
||||
To test locally, this folder lives at `<Vault>/.obsidian/plugins/obsidian-caissa/`. After building, reload Obsidian and enable **Caissa** under **Settings → Community plugins**.
|
||||
|
||||
## Credits
|
||||
|
||||
- Move logic: [chess.js](https://github.com/jhlywa/chess.js)
|
||||
- Opening statistics: [Lichess Opening Explorer](https://lichess.org/api#tag/Opening-Explorer) (CC0)
|
||||
- World Championship game PGNs: [PGN Mentor](https://pgnmentor.com/) (records of historical OTB games are not subject to copyright; only the move sequences are bundled, with the original event headers preserved)
|
||||
- Piece artwork (sourced from the [Lichess (lila) repository](https://github.com/lichess-org/lila/tree/master/public/piece); see lila's [COPYING.md](https://github.com/lichess-org/lila/blob/master/COPYING.md) for canonical license info):
|
||||
- `cburnett` — [Colin M.L. Burnett](https://en.wikipedia.org/wiki/User:Cburnett) ([GPLv2+](https://www.gnu.org/licenses/gpl-2.0.txt))
|
||||
- `merida` — Armando Hernández Marroquín ([GPLv2+](https://www.gnu.org/licenses/gpl-2.0.txt))
|
||||
- `staunty` — sadsnake1 ([CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/))
|
||||
- `caliente` — [avi](https://github.com/avi-0/caliente) ([CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/))
|
||||
- `pixel` — therealqtpi ([AGPLv3+](https://www.gnu.org/licenses/agpl-3.0.html))
|
||||
- `letter` — synthesized in this plugin
|
||||
|
||||
The CC BY-NC-SA 4.0 sets (`staunty`, `caliente`) include a non-commercial restriction. Distributing them as part of this free, open-source plugin is fine; if you fork this plugin and intend to sell it, remove those sets first.
|
||||
|
||||
To refresh the bundled SVGs from upstream, run `node scripts/fetch-piece-sets.mjs` and commit the regenerated `src/chess/piece-data.generated.ts`.
|
||||
|
||||
## Release
|
||||
|
||||
Attach `main.js`, `manifest.json`, and `styles.css` as individual assets to a GitHub release whose tag exactly matches the `version` in `manifest.json` (no leading `v`).
|
||||
53
esbuild.config.mjs
Normal file
53
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import { builtinModules } from 'node:module';
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
// Treat .sftext files as opaque strings — used to inline the bundled
|
||||
// Stockfish 10 asm.js engine into main.js so we don't need to ship
|
||||
// extra files alongside the plugin.
|
||||
loader: { ".sftext": "text" },
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
34
eslint.config.mts
Normal file
34
eslint.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'eslint.config.js',
|
||||
'manifest.json'
|
||||
]
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
]),
|
||||
);
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "obsidian-caissa",
|
||||
"name": "Caissa",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.12.0",
|
||||
"description": "Study chess in your vault. Embed positions, study openings and endgames, browse World Championship games, annotate PGNs, and explore Lichess opening statistics.",
|
||||
"author": "TheEkrizdis",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
5156
package-lock.json
generated
Normal file
5156
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "obsidian-caissa",
|
||||
"version": "1.0.0",
|
||||
"description": "Caissa — chess study in your vault. Embed positions, study openings and endgames, browse World Championship games, annotate PGNs, and explore Lichess opening statistics.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"deploy": "mkdir -p \"$HOME/Documents/Personal/.obsidian/plugins/$npm_package_name\" && cp main.js manifest.json styles.css \"$HOME/Documents/Personal/.obsidian/plugins/$npm_package_name/\"",
|
||||
"build:deploy": "npm run build && npm run deploy",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "GPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"jiti": "2.6.1",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"chess.js": "^1.4.0",
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
161
scripts/fetch-piece-sets.mjs
Normal file
161
scripts/fetch-piece-sets.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetch chess piece SVG sets from the Lichess (lila) public asset repo and
|
||||
* emit a single TypeScript data file at src/chess/piece-data.generated.ts.
|
||||
*
|
||||
* Run from the plugin root with:
|
||||
*
|
||||
* node scripts/fetch-piece-sets.mjs
|
||||
*
|
||||
* This is a build-time / dev-time tool. The plugin doesn't depend on it at
|
||||
* runtime — it just regenerates the bundled piece-data file when we add or
|
||||
* update sets. The generated file IS committed (so end users don't need to
|
||||
* fetch anything when installing the plugin).
|
||||
*
|
||||
* Adding a new set: append to SETS below, re-run, commit the regenerated
|
||||
* file. Then add the set's identifier to PieceSet in src/types.ts.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const OUT_FILE = `${ROOT}/src/chess/piece-data.generated.ts`;
|
||||
|
||||
const BASE = "https://raw.githubusercontent.com/lichess-org/lila/master/public/piece";
|
||||
|
||||
/**
|
||||
* Sets to fetch. Order = display order in settings dropdown. Each entry
|
||||
* documents the artist and license (paraphrased from Lichess's repo) for
|
||||
* inclusion in the README CREDITS section.
|
||||
*/
|
||||
const SETS = [
|
||||
{ id: "merida", license: "GPLv2+ (Armando Hernandez Marroquin)" },
|
||||
{ id: "staunty", license: "CC BY-NC-SA 4.0 (sadsnake1)" },
|
||||
{ id: "caliente", license: "CC BY-NC-SA 4.0 (avi)" },
|
||||
{ id: "pixel", license: "AGPLv3+ (therealqtpi)" },
|
||||
];
|
||||
|
||||
const PIECES = ["wK", "wQ", "wR", "wB", "wN", "wP", "bK", "bQ", "bR", "bB", "bN", "bP"];
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull viewBox metadata + inner SVG content out of a complete <svg> document.
|
||||
* Handles two cases Lichess uses:
|
||||
* - explicit `viewBox="0 0 W H"` (or with floats / negative origin)
|
||||
* - implicit viewBox via `width="N"` `height="M"` with no viewBox
|
||||
*/
|
||||
function parseSvg(svgText, label) {
|
||||
const viewBoxMatch = /viewBox\s*=\s*"([^"]+)"/.exec(svgText);
|
||||
let vbX = 0, vbY = 0, vbW = 0, vbH = 0;
|
||||
if (viewBoxMatch && viewBoxMatch[1]) {
|
||||
const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number);
|
||||
if (parts.length !== 4 || parts.some(Number.isNaN)) {
|
||||
throw new Error(`Bad viewBox "${viewBoxMatch[1]}" in ${label}`);
|
||||
}
|
||||
[vbX, vbY, vbW, vbH] = parts;
|
||||
} else {
|
||||
const wMatch = /\bwidth\s*=\s*"([^"]+)"/.exec(svgText);
|
||||
const hMatch = /\bheight\s*=\s*"([^"]+)"/.exec(svgText);
|
||||
if (!wMatch || !hMatch) {
|
||||
throw new Error(`No viewBox or width/height in ${label}`);
|
||||
}
|
||||
vbW = parseFloat(wMatch[1]);
|
||||
vbH = parseFloat(hMatch[1]);
|
||||
if (Number.isNaN(vbW) || Number.isNaN(vbH)) {
|
||||
throw new Error(`Could not parse width/height in ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
const openTagMatch = /<svg\b[^>]*>/.exec(svgText);
|
||||
if (!openTagMatch) throw new Error(`No <svg> open tag in ${label}`);
|
||||
const closeIdx = svgText.lastIndexOf("</svg>");
|
||||
if (closeIdx < 0) throw new Error(`No </svg> close tag in ${label}`);
|
||||
const inner = svgText
|
||||
.slice(openTagMatch.index + openTagMatch[0].length, closeIdx)
|
||||
.trim()
|
||||
.replace(/<\?xml[^>]*\?>/g, "")
|
||||
.replace(/<!DOCTYPE[^>]*>/g, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return { vbX, vbY, vbW, vbH, inner };
|
||||
}
|
||||
|
||||
function escForTemplate(s) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Fetching ${SETS.length} sets x ${PIECES.length} pieces...`);
|
||||
const sets = {};
|
||||
for (const set of SETS) {
|
||||
sets[set.id] = { license: set.license, vbX: 0, vbY: 0, vbW: 0, vbH: 0, pieces: {} };
|
||||
for (const piece of PIECES) {
|
||||
const url = `${BASE}/${set.id}/${piece}.svg`;
|
||||
const text = await fetchText(url);
|
||||
const parsed = parseSvg(text, `${set.id}/${piece}.svg`);
|
||||
if (sets[set.id].vbW === 0) {
|
||||
sets[set.id].vbX = parsed.vbX;
|
||||
sets[set.id].vbY = parsed.vbY;
|
||||
sets[set.id].vbW = parsed.vbW;
|
||||
sets[set.id].vbH = parsed.vbH;
|
||||
}
|
||||
const lcKey = piece[0] + piece[1].toLowerCase();
|
||||
sets[set.id].pieces[lcKey] = parsed.inner;
|
||||
process.stdout.write(` ${set.id}/${piece}\r`);
|
||||
}
|
||||
console.log(
|
||||
` ${set.id}: viewBox ${sets[set.id].vbX} ${sets[set.id].vbY} ` +
|
||||
`${sets[set.id].vbW} ${sets[set.id].vbH} ` +
|
||||
`(${PIECES.length} pieces)`
|
||||
);
|
||||
}
|
||||
|
||||
let out =
|
||||
"// AUTO-GENERATED by scripts/fetch-piece-sets.mjs — do not edit by hand.\n" +
|
||||
"// Source: https://github.com/lichess-org/lila/tree/master/public/piece\n" +
|
||||
"// Re-run with: node scripts/fetch-piece-sets.mjs\n\n" +
|
||||
'import type { PieceKey } from "./pieces-types";\n\n' +
|
||||
"export interface PieceSetData {\n" +
|
||||
"\tlicense: string;\n" +
|
||||
"\tvbX: number;\n" +
|
||||
"\tvbY: number;\n" +
|
||||
"\tvbW: number;\n" +
|
||||
"\tvbH: number;\n" +
|
||||
"\tpieces: Record<PieceKey, string>;\n" +
|
||||
"}\n\n" +
|
||||
"export const PIECE_SETS: Record<string, PieceSetData> = {\n";
|
||||
for (const id of Object.keys(sets)) {
|
||||
const data = sets[id];
|
||||
out += `\t${id}: {\n`;
|
||||
out += `\t\tlicense: ${JSON.stringify(data.license)},\n`;
|
||||
out += `\t\tvbX: ${data.vbX},\n`;
|
||||
out += `\t\tvbY: ${data.vbY},\n`;
|
||||
out += `\t\tvbW: ${data.vbW},\n`;
|
||||
out += `\t\tvbH: ${data.vbH},\n`;
|
||||
out += `\t\tpieces: {\n`;
|
||||
for (const key of Object.keys(data.pieces)) {
|
||||
out += `\t\t\t${key}: \`${escForTemplate(data.pieces[key])}\`,\n`;
|
||||
}
|
||||
out += `\t\t},\n`;
|
||||
out += `\t},\n`;
|
||||
}
|
||||
out += "};\n";
|
||||
|
||||
await mkdir(dirname(OUT_FILE), { recursive: true });
|
||||
await writeFile(OUT_FILE, out, "utf8");
|
||||
console.log(`Wrote ${OUT_FILE} (${out.length} bytes)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
212
scripts/fetch-wcc-games.mjs
Normal file
212
scripts/fetch-wcc-games.mjs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* One-shot build script. Reads scripts/wcc-curated.json, downloads each
|
||||
* match's PGN from PGN Mentor, extracts the curated game rounds, and emits
|
||||
* src/chess/wcc-data.generated.ts with everything inlined.
|
||||
*
|
||||
* Run from the plugin root: `node scripts/fetch-wcc-games.mjs`
|
||||
*
|
||||
* The generated file is committed to the repo so end users never make any
|
||||
* network calls. Only re-run this script when curating new matches/games.
|
||||
*/
|
||||
|
||||
import { Chess } from "chess.js";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const CURATED_PATH = path.join(ROOT, "scripts", "wcc-curated.json");
|
||||
const OUT_PATH = path.join(ROOT, "src", "chess", "wcc-data.generated.ts");
|
||||
|
||||
const USER_AGENT = "obsidian-caissa-build-script";
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT, Accept: "application/x-chess-pgn,*/*" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a multi-game PGN file on blank lines between games. Returns an array
|
||||
* of single-game PGN strings (each still containing its [Tag "value"] block
|
||||
* and the move text).
|
||||
*/
|
||||
function splitPgnIntoGames(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const games = [];
|
||||
let current = [];
|
||||
let inMoves = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
if (inMoves && current.length > 0) {
|
||||
games.push(current.join("\n").trim());
|
||||
current = [];
|
||||
}
|
||||
inMoves = false;
|
||||
current.push(line);
|
||||
} else if (trimmed === "") {
|
||||
if (current.length > 0) current.push(line);
|
||||
} else {
|
||||
inMoves = true;
|
||||
current.push(line);
|
||||
}
|
||||
}
|
||||
if (current.length > 0) games.push(current.join("\n").trim());
|
||||
return games.filter((g) => g.length > 0);
|
||||
}
|
||||
|
||||
/** Pull a single tag value out of a single-game PGN string. */
|
||||
function getHeader(pgn, tag) {
|
||||
const re = new RegExp(`\\[\\s*${tag}\\s+"((?:[^"\\\\]|\\\\.)*)"\\s*\\]`, "i");
|
||||
const m = pgn.match(re);
|
||||
if (!m) return undefined;
|
||||
return m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
|
||||
/** Verify the PGN is parseable and at least one move was played. */
|
||||
function validatePgn(pgn) {
|
||||
const game = new Chess();
|
||||
game.loadPgn(pgn, { strict: false });
|
||||
const history = game.history();
|
||||
return history.length;
|
||||
}
|
||||
|
||||
function jsString(s) {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const curated = JSON.parse(fs.readFileSync(CURATED_PATH, "utf8"));
|
||||
const matches = curated.matches;
|
||||
|
||||
const collected = [];
|
||||
let totalSizeBytes = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
console.log(`\n=== ${match.matchLabel} ===`);
|
||||
let pgnText;
|
||||
try {
|
||||
pgnText = await fetchText(match.pgnUrl);
|
||||
} catch (e) {
|
||||
console.warn(` FETCH FAILED: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
const games = splitPgnIntoGames(pgnText);
|
||||
console.log(` Downloaded ${games.length} games (${pgnText.length} bytes)`);
|
||||
|
||||
for (const targetRound of match.games) {
|
||||
const found = games.find((g) => {
|
||||
const round = getHeader(g, "Round");
|
||||
if (!round) return false;
|
||||
const num = parseInt(round, 10);
|
||||
return !isNaN(num) && num === targetRound;
|
||||
});
|
||||
if (!found) {
|
||||
console.warn(` MISS round ${targetRound}`);
|
||||
continue;
|
||||
}
|
||||
let plyCount;
|
||||
try {
|
||||
plyCount = validatePgn(found);
|
||||
} catch (e) {
|
||||
console.warn(` INVALID round ${targetRound}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
if (plyCount === 0) {
|
||||
console.warn(` EMPTY round ${targetRound} (no moves recorded — forfeit?)`);
|
||||
continue;
|
||||
}
|
||||
const slug = `${match.matchSlug}-${String(targetRound).padStart(2, "0")}`;
|
||||
const white = getHeader(found, "White") ?? "?";
|
||||
const black = getHeader(found, "Black") ?? "?";
|
||||
const result = getHeader(found, "Result") ?? "*";
|
||||
console.log(` OK round ${targetRound}: ${white} vs ${black} (${result}, ${plyCount} ply)`);
|
||||
totalSizeBytes += found.length;
|
||||
collected.push({
|
||||
id: slug,
|
||||
matchSlug: match.matchSlug,
|
||||
matchLabel: match.matchLabel,
|
||||
matchYear: match.matchYear,
|
||||
gameNumber: targetRound,
|
||||
white,
|
||||
black,
|
||||
result,
|
||||
pgn: found,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nCollected ${collected.length} games (${totalSizeBytes} bytes of raw PGN).`);
|
||||
|
||||
const matchesOrdered = matches
|
||||
.map((m) => ({
|
||||
matchSlug: m.matchSlug,
|
||||
matchLabel: m.matchLabel,
|
||||
matchYear: m.matchYear,
|
||||
}))
|
||||
.filter((m) => collected.some((g) => g.matchSlug === m.matchSlug));
|
||||
|
||||
const out = [];
|
||||
out.push("// AUTO-GENERATED by scripts/fetch-wcc-games.mjs — do not edit by hand.");
|
||||
out.push("// Re-run that script to refresh.");
|
||||
out.push("");
|
||||
out.push("export interface WccGameData {");
|
||||
out.push("\tid: string;");
|
||||
out.push("\tmatchSlug: string;");
|
||||
out.push("\tmatchLabel: string;");
|
||||
out.push("\tmatchYear: number;");
|
||||
out.push("\tgameNumber: number;");
|
||||
out.push("\twhite: string;");
|
||||
out.push("\tblack: string;");
|
||||
out.push("\tresult: string;");
|
||||
out.push("\tpgn: string;");
|
||||
out.push("}");
|
||||
out.push("");
|
||||
out.push("export interface WccMatchData {");
|
||||
out.push("\tmatchSlug: string;");
|
||||
out.push("\tmatchLabel: string;");
|
||||
out.push("\tmatchYear: number;");
|
||||
out.push("}");
|
||||
out.push("");
|
||||
out.push("export const WCC_MATCHES: WccMatchData[] = [");
|
||||
for (const m of matchesOrdered) {
|
||||
out.push("\t{");
|
||||
out.push(`\t\tmatchSlug: ${jsString(m.matchSlug)},`);
|
||||
out.push(`\t\tmatchLabel: ${jsString(m.matchLabel)},`);
|
||||
out.push(`\t\tmatchYear: ${m.matchYear},`);
|
||||
out.push("\t},");
|
||||
}
|
||||
out.push("];");
|
||||
out.push("");
|
||||
out.push("export const WCC_GAMES: WccGameData[] = [");
|
||||
for (const g of collected) {
|
||||
out.push("\t{");
|
||||
out.push(`\t\tid: ${jsString(g.id)},`);
|
||||
out.push(`\t\tmatchSlug: ${jsString(g.matchSlug)},`);
|
||||
out.push(`\t\tmatchLabel: ${jsString(g.matchLabel)},`);
|
||||
out.push(`\t\tmatchYear: ${g.matchYear},`);
|
||||
out.push(`\t\tgameNumber: ${g.gameNumber},`);
|
||||
out.push(`\t\twhite: ${jsString(g.white)},`);
|
||||
out.push(`\t\tblack: ${jsString(g.black)},`);
|
||||
out.push(`\t\tresult: ${jsString(g.result)},`);
|
||||
out.push(`\t\tpgn: ${jsString(g.pgn)},`);
|
||||
out.push("\t},");
|
||||
}
|
||||
out.push("];");
|
||||
out.push("");
|
||||
|
||||
fs.writeFileSync(OUT_PATH, out.join("\n"), "utf8");
|
||||
console.log(`\nWrote ${OUT_PATH}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
145
scripts/wcc-curated.json
Normal file
145
scripts/wcc-curated.json
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
{
|
||||
"_comment": "Curated WCC games to bundle. Each match downloads the full PGN from PGN Mentor and the script extracts the listed game rounds. matchSlug is used in lookup IDs (e.g. 1972-fischer-spassky-06).",
|
||||
"matches": [
|
||||
{
|
||||
"matchSlug": "1886-steinitz-zukertort",
|
||||
"matchLabel": "1886 — Steinitz vs Zukertort",
|
||||
"matchYear": 1886,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1886.pgn",
|
||||
"games": [1, 9, 20]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1894-lasker-steinitz",
|
||||
"matchLabel": "1894 — Lasker vs Steinitz",
|
||||
"matchYear": 1894,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1894.pgn",
|
||||
"games": [5, 7, 17, 19]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1921-capablanca-lasker",
|
||||
"matchLabel": "1921 — Capablanca vs Lasker",
|
||||
"matchYear": 1921,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1921.pgn",
|
||||
"games": [5, 10, 11, 14]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1927-alekhine-capablanca",
|
||||
"matchLabel": "1927 — Alekhine vs Capablanca",
|
||||
"matchYear": 1927,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1927.pgn",
|
||||
"games": [1, 11, 21, 32, 34]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1948-botvinnik-tournament",
|
||||
"matchLabel": "1948 — Botvinnik 1st (tournament)",
|
||||
"matchYear": 1948,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1948.pgn",
|
||||
"games": [9, 14, 21]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1951-botvinnik-bronstein",
|
||||
"matchLabel": "1951 — Botvinnik vs Bronstein",
|
||||
"matchYear": 1951,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1951.pgn",
|
||||
"games": [12, 17, 22]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1960-tal-botvinnik",
|
||||
"matchLabel": "1960 — Tal vs Botvinnik",
|
||||
"matchYear": 1960,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1960.pgn",
|
||||
"games": [1, 6, 17]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1972-fischer-spassky",
|
||||
"matchLabel": "1972 — Fischer vs Spassky",
|
||||
"matchYear": 1972,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1972.pgn",
|
||||
"games": [1, 3, 6, 8, 13, 21]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1985-kasparov-karpov",
|
||||
"matchLabel": "1985 — Kasparov vs Karpov",
|
||||
"matchYear": 1985,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1985.pgn",
|
||||
"games": [4, 11, 16, 19, 24]
|
||||
},
|
||||
{
|
||||
"matchSlug": "1990-kasparov-karpov",
|
||||
"matchLabel": "1990 — Kasparov vs Karpov",
|
||||
"matchYear": 1990,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp1990.pgn",
|
||||
"games": [2, 18, 20]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2000-kramnik-kasparov",
|
||||
"matchLabel": "2000 — Kramnik vs Kasparov",
|
||||
"matchYear": 2000,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2000.pgn",
|
||||
"games": [1, 2, 10, 13]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2008-anand-kramnik",
|
||||
"matchLabel": "2008 — Anand vs Kramnik",
|
||||
"matchYear": 2008,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2008.pgn",
|
||||
"games": [3, 5, 6]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2010-anand-topalov",
|
||||
"matchLabel": "2010 — Anand vs Topalov",
|
||||
"matchYear": 2010,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2010.pgn",
|
||||
"games": [4, 12]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2013-carlsen-anand",
|
||||
"matchLabel": "2013 — Carlsen vs Anand",
|
||||
"matchYear": 2013,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2013.pgn",
|
||||
"games": [5, 6, 9]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2014-carlsen-anand",
|
||||
"matchLabel": "2014 — Carlsen vs Anand",
|
||||
"matchYear": 2014,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2014.pgn",
|
||||
"games": [2, 6, 11]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2016-carlsen-karjakin",
|
||||
"matchLabel": "2016 — Carlsen vs Karjakin",
|
||||
"matchYear": 2016,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2016.pgn",
|
||||
"games": [8, 10]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2018-carlsen-caruana",
|
||||
"matchLabel": "2018 — Carlsen vs Caruana",
|
||||
"matchYear": 2018,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2018.pgn",
|
||||
"games": [1, 6, 12]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2021-carlsen-nepomniachtchi",
|
||||
"matchLabel": "2021 — Carlsen vs Nepomniachtchi",
|
||||
"matchYear": 2021,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2021.pgn",
|
||||
"games": [6, 8, 9]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2023-ding-nepomniachtchi",
|
||||
"matchLabel": "2023 — Ding vs Nepomniachtchi",
|
||||
"matchYear": 2023,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2023.pgn",
|
||||
"games": [4, 6, 12]
|
||||
},
|
||||
{
|
||||
"matchSlug": "2024-gukesh-ding",
|
||||
"matchLabel": "2024 — Gukesh vs Ding",
|
||||
"matchYear": 2024,
|
||||
"pgnUrl": "https://pgnmentor.com/events/WorldChamp2024.pgn",
|
||||
"games": [3, 11, 14]
|
||||
}
|
||||
]
|
||||
}
|
||||
120
src/chess/endgames.ts
Normal file
120
src/chess/endgames.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Curated set of canonical endgame techniques. Each entry has:
|
||||
* - a starting FEN (the textbook position)
|
||||
* - an optional move sequence demonstrating the standard technique
|
||||
* - a short description of the idea
|
||||
*
|
||||
* Move strings are SAN, fed straight into chess.js — keep them legal from the
|
||||
* given FEN. For positions where the technique is too branchy to teach via a
|
||||
* single line we leave `moves` undefined and let the user step around the
|
||||
* position freely (the opening explorer panel still works).
|
||||
*/
|
||||
|
||||
export interface Endgame {
|
||||
/** Stable slug used in `endgame:` block keys. */
|
||||
id: string;
|
||||
name: string;
|
||||
fen: string;
|
||||
moves?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ENDGAMES: Endgame[] = [
|
||||
{
|
||||
id: "kqk-mate",
|
||||
name: "King and queen mate",
|
||||
fen: "8/8/8/3k4/8/8/4Q3/4K3 w - - 0 1",
|
||||
moves: "Qd3+ Kc5 Kd2 Kc6 Kc3 Kb6 Qb5+ Ka7 Kc4 Ka8 Kc5 Ka7 Kc6 Ka8 Qb7#",
|
||||
description:
|
||||
"The queen mate technique: keep the queen one knight's-move from the lone king to shrink its box without stalemating, while your king walks up to support. The line shown drives Black's king from d5 to a8 over seven moves and finishes with Qb7# — the queen defended by your king on c6, every escape square covered. Black's defense in this demo is one plausible line; other king moves transpose into the same pattern.",
|
||||
},
|
||||
{
|
||||
id: "krk-mate",
|
||||
name: "King and rook mate",
|
||||
fen: "4k3/8/8/8/4K3/8/8/R7 w - - 0 1",
|
||||
moves: "Ra7 Kf8 Kf5 Kg8 Kg6 Kh8 Ra8#",
|
||||
description:
|
||||
"With king and rook the technique is the rook cutting off ranks while your king escorts the enemy toward an edge. The line shown plays 1.Ra7 to lock Black on the back rank, then walks the white king up via f5/g6 with shoulder-to-shoulder opposition, and finishes with Ra8# on the corner. Other defensive king moves lead to the same pattern.",
|
||||
},
|
||||
{
|
||||
id: "krrk-ladder-mate",
|
||||
name: "Two rooks ladder mate",
|
||||
fen: "4k3/8/8/8/8/8/R7/1R5K w - - 0 1",
|
||||
moves: "Rb7 Kd8 Ra8#",
|
||||
description:
|
||||
"The 'ladder': one rook cuts off the 7th rank, the other delivers mate on the 8th. 1.Rb7 forces the black king onto the back rank (Kd8 or Kf8 — both lose), and 2.Ra8# completes the staircase. Two rooks corner the king without any king help needed, which is why this mate is so quick.",
|
||||
},
|
||||
{
|
||||
id: "kbnk-mate",
|
||||
name: "Bishop and knight mate",
|
||||
fen: "8/2k5/8/1K6/5N2/8/5B2/8 w - - 0 84",
|
||||
moves:
|
||||
"Bc5 Kb7 Nd5 Kb8 Kc6 Ka8 Nc7+ Kb8 Bd4 Kc8 Ba7 Kd8 Nd5 Ke8 " +
|
||||
"Kd6 Kf7 Ne7 Kf6 Be3 Kf7 Bd4 Ke8 Ke6 Kd8 Bb6+ Ke8 Nf5 Kf8 " +
|
||||
"Bc7 Ke8 Ng7+ Kf8 Kf6 Kg8 Bd6 Kh7 Nf5 Kg8 Kg6 Kh8 Bc5 Kg8 " +
|
||||
"Nh6+ Kh8 Bd4#",
|
||||
description:
|
||||
"The hardest of the basic mates — KBNK can only be forced in the corner *matching your bishop's color*, and even then takes ~30 moves with the W-manoeuvre. The line shown is from **Karttunen vs Rasik, European Club Cup 2003**, picked up at move 84 with 23 moves left to mate. Watch the dark-squared bishop, knight and king herd Black across the back rank from the wrong corner (a8) all the way to the dark corner (h8), with knight checks at c7, e7, g7, f5 doing the W-pattern. The original game ended with Black resigning after 104.Bc5; this line plays out the forced finish 104…Kg8 105.Nh6+ Kh8 106.Bd4# that Rasik would have faced.",
|
||||
},
|
||||
{
|
||||
id: "kbbk-mate",
|
||||
name: "Two bishops mate",
|
||||
fen: "3B4/8/8/8/Bk6/8/8/7K w - - 0 1",
|
||||
moves:
|
||||
"Bc6 Kc5 Bb7 Kd6 Kg2 Kd7 Bb6 Kd6 Kf3 Ke5 Be4 Kd6 Kf4 Ke6 " +
|
||||
"Bc5 Kd7 Ke5 Kc7 Ke6 Kc8 Kd6 Kd8 Kc6 Ke8 Bd5 Kd8 Bf7 Kc8 " +
|
||||
"Be7 Kb8 Kb6 Kc8 Be6+ Kb8 Bd6+ Ka8 Bd5#",
|
||||
description:
|
||||
"The two bishops form a wall that herds the lone king to any corner — either color works (unlike B+N). The line shown is the **longest forced mate in KBBK (mate in 19)** from Joe Leslie-Hurd's 2005 tablebase analysis, with both sides playing perfectly. Watch the bishops centralize on b7+e4, the white king march from h1 → f4 → e6 → c6, and the final mating net snap shut on a8 with 19.Bd5#. Black's defense is optimal — every other reply mates faster.",
|
||||
},
|
||||
{
|
||||
id: "lucena",
|
||||
name: "Lucena position (winning the rook endgame)",
|
||||
fen: "1K1k4/1P6/8/8/8/8/r7/2R5 w - - 0 1",
|
||||
description:
|
||||
"With your king in front of a 7th-rank pawn (not a- or h-file) and your rook free, build a 'bridge' on the 4th rank (Rc4!), then march your king out behind it. The defending rook eventually runs out of useful checks and the pawn promotes.",
|
||||
},
|
||||
{
|
||||
id: "philidor",
|
||||
name: "Philidor position (drawing the rook endgame)",
|
||||
fen: "3k4/8/8/4P3/4K3/7r/8/3R4 b - - 0 1",
|
||||
description:
|
||||
"As the defender, plant your rook on the third rank to keep the enemy king off the 6th. The instant the pawn pushes to e6, swing the rook behind the pawn (…Rh1) and check from the long side. Draw.",
|
||||
},
|
||||
{
|
||||
id: "vancura",
|
||||
name: "Vancura position (rook + a-pawn draw)",
|
||||
fen: "8/K7/P4r2/8/8/8/8/4k3 b - - 0 1",
|
||||
description:
|
||||
"Defender's rook attacks the a-pawn from the side along the 6th rank, the defender's king cuts the long side. The white king can never both shield the pawn and escape the side checks — drawn.",
|
||||
},
|
||||
{
|
||||
id: "kpk-opposition",
|
||||
name: "King and pawn vs king (opposition)",
|
||||
fen: "8/8/4K3/4P3/8/8/8/4k3 w - - 0 1",
|
||||
description:
|
||||
"With your king *in front of* the pawn on the 6th rank, you win by gaining the opposition (kings face off with one square between them, opponent to move). If the defender takes the opposition with the king on the key squares first, the position is drawn.",
|
||||
},
|
||||
{
|
||||
id: "wrong-bishop",
|
||||
name: "Wrong-colored bishop draw",
|
||||
fen: "7k/8/6KP/8/3B4/8/8/8 w - - 0 1",
|
||||
description:
|
||||
"With a rook-pawn whose promotion square does *not* match your bishop's color, the defender hides in the corner. Any approach by the strong side ends in stalemate (push the pawn here and the black king has no legal move). Memorise the corner colors before trading into this ending.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Find an endgame by id or display name (case- and punctuation-tolerant). */
|
||||
export function findEndgame(idOrName: string | undefined): Endgame | null {
|
||||
if (!idOrName) return null;
|
||||
const target = normalize(idOrName);
|
||||
return (
|
||||
ENDGAMES.find(
|
||||
(e) => normalize(e.id) === target || normalize(e.name) === target
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
}
|
||||
309
src/chess/engine-worker.ts
Normal file
309
src/chess/engine-worker.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import engineSource from "../vendor/stockfish-engine.sftext";
|
||||
|
||||
/**
|
||||
* Bundled-Stockfish (v10 asm.js, ~1.5MB inlined) wrapper.
|
||||
*
|
||||
* We don't ship Stockfish as a separate file because Obsidian plugin
|
||||
* installs only sync `main.js`/`manifest.json`/`styles.css` to the vault
|
||||
* and reading vendored vault files for Worker bootstrap is fragile across
|
||||
* desktop and mobile. Inlining as a string and spawning the worker from a
|
||||
* Blob URL keeps everything self-contained.
|
||||
*
|
||||
* This is pure asm.js (no WASM), so:
|
||||
* - No CSP issues with WebAssembly.compile / wasm-eval
|
||||
* - Works in Electron and on mobile
|
||||
* - Strength is ~3000+ ELO at depth 18 (HCE, no NNUE) — plenty for
|
||||
* analysis-style use cases (eval bar, top lines, blunder detection)
|
||||
*
|
||||
* Module-level singleton: only one engine is alive at a time. That's fine
|
||||
* because Stockfish is single-threaded and serializing analyses through
|
||||
* one engine matches the user's mental model (one position analyzed at a
|
||||
* time per board, latest request wins).
|
||||
*/
|
||||
|
||||
export interface EngineLine {
|
||||
/** 1-indexed PV rank (1 = top line). */
|
||||
multipv: number;
|
||||
/** Centipawn evaluation from White's perspective. */
|
||||
cp: number | null;
|
||||
/** Mate distance in plies (positive = White mates, negative = Black). */
|
||||
mate: number | null;
|
||||
/** Search depth reached for this line. */
|
||||
depth: number;
|
||||
/** Principal variation as UCI moves (e.g. ["e2e4", "e7e5"]). */
|
||||
pv: string[];
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
/** Top-N lines, sorted by multipv ascending. */
|
||||
lines: EngineLine[];
|
||||
/** Best move chosen by the engine (UCI like "e2e4"). */
|
||||
bestMove: string | null;
|
||||
}
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
/** Max search depth (default 16). Higher = stronger but slower. */
|
||||
depth?: number;
|
||||
/** Number of PV lines to return (default 3). */
|
||||
multiPV?: number;
|
||||
/**
|
||||
* AbortSignal — when fired we issue a UCI `stop` and the promise
|
||||
* resolves with whatever lines have been seen so far.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface PlayMoveOptions {
|
||||
/**
|
||||
* Stockfish "Skill Level" UCI option, 0–20. Lower picks weaker moves
|
||||
* intentionally so the engine plays at human-ish strength. Default 8.
|
||||
* 0 ≈ ~1100 Elo (random-ish, beginner)
|
||||
* 8 ≈ ~1700 Elo (decent club player)
|
||||
* 15 ≈ ~2200 Elo (expert)
|
||||
* 20 = full strength (~3000+ in this build)
|
||||
*/
|
||||
skillLevel?: number;
|
||||
/** Search depth ceiling. Defaults scale with skillLevel. */
|
||||
depth?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
class StockfishEngine {
|
||||
private worker: Worker | null = null;
|
||||
private ready: Promise<void> | null = null;
|
||||
private currentResolve: ((res: AnalysisResult) => void) | null = null;
|
||||
private currentLines = new Map<number, EngineLine>();
|
||||
private currentBestMove: string | null = null;
|
||||
private currentSignal: AbortSignal | null = null;
|
||||
private currentSignalHandler: (() => void) | null = null;
|
||||
private busy = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
/** Lazy-spawn the worker on first analyze; safe to call repeatedly. */
|
||||
private ensureReady(): Promise<void> {
|
||||
if (this.ready) return this.ready;
|
||||
this.ready = new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const blob = new Blob([engineSource], {
|
||||
type: "application/javascript",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.worker = new Worker(url);
|
||||
this.worker.onmessage = (ev: MessageEvent<string>) => {
|
||||
this.handleLine(ev.data);
|
||||
};
|
||||
this.worker.onerror = (ev) => {
|
||||
console.error("Stockfish worker error", ev);
|
||||
};
|
||||
|
||||
let acked = false;
|
||||
const ackHandler = (ev: MessageEvent<string>) => {
|
||||
if (acked) return;
|
||||
if (typeof ev.data === "string" && ev.data.startsWith("uciok")) {
|
||||
acked = true;
|
||||
this.worker?.removeEventListener("message", ackHandler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.worker.addEventListener("message", ackHandler);
|
||||
|
||||
this.send("uci");
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
async analyze(
|
||||
fen: string,
|
||||
opts: AnalyzeOptions = {}
|
||||
): Promise<AnalysisResult> {
|
||||
const depth = Math.max(1, Math.min(opts.depth ?? 16, 24));
|
||||
const multiPV = Math.max(1, Math.min(opts.multiPV ?? 3, 5));
|
||||
return this.runSearch(fen, opts.signal, [
|
||||
`setoption name Skill Level value 20`,
|
||||
`setoption name MultiPV value ${multiPV}`,
|
||||
`position fen ${fen}`,
|
||||
`go depth ${depth}`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a move at "human" strength using Stockfish's Skill Level option.
|
||||
* Returns the bestmove in UCI form (e.g. "e2e4" or "e7e8q") or null if
|
||||
* the position has no legal moves.
|
||||
*/
|
||||
async findBestMove(
|
||||
fen: string,
|
||||
opts: PlayMoveOptions = {}
|
||||
): Promise<string | null> {
|
||||
const skill = Math.max(0, Math.min(opts.skillLevel ?? 8, 20));
|
||||
// Lower skill levels also get shallower searches so the engine
|
||||
// makes its weak moves quickly. Caller can override `depth` to
|
||||
// pin a specific search budget.
|
||||
const defaultDepth = 4 + Math.floor(skill / 2);
|
||||
const depth = Math.max(1, Math.min(opts.depth ?? defaultDepth, 20));
|
||||
const res = await this.runSearch(fen, opts.signal, [
|
||||
`setoption name Skill Level value ${skill}`,
|
||||
`setoption name MultiPV value 1`,
|
||||
`position fen ${fen}`,
|
||||
`go depth ${depth}`,
|
||||
]);
|
||||
return res.bestMove;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "send these UCI commands and wait for `bestmove`" path used
|
||||
* by both analyze and findBestMove. The caller passes the full command
|
||||
* sequence (including the position + go), and we collect every `info`
|
||||
* line plus the final `bestmove`.
|
||||
*/
|
||||
private async runSearch(
|
||||
_fen: string,
|
||||
signal: AbortSignal | undefined,
|
||||
commands: string[]
|
||||
): Promise<AnalysisResult> {
|
||||
await this.ensureReady();
|
||||
// Serialize requests through a tiny in-memory queue. Stockfish only
|
||||
// runs one search at a time, so queueing is the cleanest contract.
|
||||
if (this.busy) {
|
||||
await new Promise<void>((resolve) => this.queue.push(resolve));
|
||||
}
|
||||
this.busy = true;
|
||||
|
||||
this.currentLines.clear();
|
||||
this.currentBestMove = null;
|
||||
|
||||
return new Promise<AnalysisResult>((resolve) => {
|
||||
this.currentResolve = resolve;
|
||||
this.currentSignal = signal ?? null;
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
this.send("stop");
|
||||
} else {
|
||||
this.currentSignalHandler = () => this.send("stop");
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
this.currentSignalHandler,
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `ucinewgame` between searches keeps Stockfish from carrying
|
||||
// pondering / killer-move state across positions, which would
|
||||
// otherwise leak strength even when Skill Level is low.
|
||||
this.send("ucinewgame");
|
||||
for (const cmd of commands) this.send(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
private send(cmd: string): void {
|
||||
this.worker?.postMessage(cmd);
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (!line) return;
|
||||
if (line.startsWith("info ")) {
|
||||
const parsed = parseInfoLine(line);
|
||||
if (parsed) {
|
||||
this.currentLines.set(parsed.multipv, parsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("bestmove ")) {
|
||||
const parts = line.split(/\s+/);
|
||||
this.currentBestMove =
|
||||
parts[1] && parts[1] !== "(none)" ? parts[1] : null;
|
||||
this.finishCurrent();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private finishCurrent(): void {
|
||||
const resolve = this.currentResolve;
|
||||
const lines = Array.from(this.currentLines.values()).sort(
|
||||
(a, b) => a.multipv - b.multipv
|
||||
);
|
||||
const bestMove = this.currentBestMove;
|
||||
|
||||
if (this.currentSignal && this.currentSignalHandler) {
|
||||
this.currentSignal.removeEventListener(
|
||||
"abort",
|
||||
this.currentSignalHandler
|
||||
);
|
||||
}
|
||||
|
||||
this.currentResolve = null;
|
||||
this.currentLines.clear();
|
||||
this.currentBestMove = null;
|
||||
this.currentSignal = null;
|
||||
this.currentSignalHandler = null;
|
||||
this.busy = false;
|
||||
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
|
||||
resolve?.({ lines, bestMove });
|
||||
}
|
||||
|
||||
/** Tear down the worker. Called when the plugin unloads. */
|
||||
terminate(): void {
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.ready = null;
|
||||
}
|
||||
}
|
||||
|
||||
let SINGLETON: StockfishEngine | null = null;
|
||||
|
||||
export function getEngine(): StockfishEngine {
|
||||
if (!SINGLETON) SINGLETON = new StockfishEngine();
|
||||
return SINGLETON;
|
||||
}
|
||||
|
||||
export function terminateEngine(): void {
|
||||
SINGLETON?.terminate();
|
||||
SINGLETON = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one UCI `info` line into a structured EngineLine. Returns null for
|
||||
* lines without enough fields to be useful (e.g. "info string ..." log
|
||||
* spam, or info lines without a PV yet).
|
||||
*/
|
||||
function parseInfoLine(line: string): EngineLine | null {
|
||||
const tokens = line.split(/\s+/);
|
||||
let depth = 0;
|
||||
let multipv = 1;
|
||||
let cp: number | null = null;
|
||||
let mate: number | null = null;
|
||||
let pv: string[] = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const tok = tokens[i];
|
||||
switch (tok) {
|
||||
case "depth":
|
||||
depth = parseInt(tokens[++i] ?? "0", 10) || 0;
|
||||
break;
|
||||
case "multipv":
|
||||
multipv = parseInt(tokens[++i] ?? "1", 10) || 1;
|
||||
break;
|
||||
case "score": {
|
||||
const kind = tokens[++i];
|
||||
const value = parseInt(tokens[++i] ?? "0", 10) || 0;
|
||||
if (kind === "cp") cp = value;
|
||||
else if (kind === "mate") mate = value;
|
||||
break;
|
||||
}
|
||||
case "pv":
|
||||
pv = tokens.slice(i + 1);
|
||||
i = tokens.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pv.length === 0) return null;
|
||||
return { multipv, cp, mate, depth, pv };
|
||||
}
|
||||
340
src/chess/engine.ts
Normal file
340
src/chess/engine.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { Chess } from "chess.js";
|
||||
import { findOpening } from "./openings";
|
||||
import { findEndgame } from "./endgames";
|
||||
import { findWccGame } from "./wcc-games";
|
||||
import type { PgnHeaders, PieceType } from "../types";
|
||||
|
||||
export interface PositionStep {
|
||||
/** FEN string for this position. */
|
||||
fen: string;
|
||||
/** SAN move that produced this position (undefined for the starting position). */
|
||||
san?: string;
|
||||
/** Origin square (e.g. "e2"). */
|
||||
from?: string;
|
||||
/** Destination square (e.g. "e4"). */
|
||||
to?: string;
|
||||
/** Color that just moved (undefined for the starting position). */
|
||||
color?: "w" | "b";
|
||||
/** 1-based full-move number this move belongs to (1 for white's first move). */
|
||||
moveNumber?: number;
|
||||
/** Type of piece captured by this move, if any. Drives the captured-pieces tray. */
|
||||
captured?: PieceType;
|
||||
/** Type the moving pawn was promoted to, if any. Used for material balance. */
|
||||
promotion?: PieceType;
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
steps: PositionStep[];
|
||||
resolvedTitle?: string;
|
||||
resolvedDescription?: string;
|
||||
headers?: PgnHeaders;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sequence of board positions from a code-block config.
|
||||
*
|
||||
* Resolution order (first match wins):
|
||||
* 1. full PGN string (`pgn`) — extracts headers, starting position, and moves
|
||||
* 2. WCC game slug (`wccgame`) — resolves to a bundled PGN
|
||||
* 3. endgame slug/name (`endgame`) — bundled FEN + optional move sequence
|
||||
* 4. starting FEN + explicit `moves` string
|
||||
* 5. opening + variation lookup
|
||||
* 6. starting FEN alone (just the position)
|
||||
*
|
||||
* Move strings can be plain SAN (`e4 c5 Nf3`) or PGN-ish with numbers
|
||||
* (`1. e4 c5 2. Nf3`) — both are tolerated.
|
||||
*/
|
||||
export function buildPositions(args: {
|
||||
opening?: string;
|
||||
variation?: string;
|
||||
endgame?: string;
|
||||
wccgame?: string;
|
||||
moves?: string;
|
||||
pgn?: string;
|
||||
fen?: string;
|
||||
}): BuildResult {
|
||||
if (args.pgn?.trim()) {
|
||||
return buildFromPgn(args.pgn.trim());
|
||||
}
|
||||
|
||||
if (args.wccgame?.trim()) {
|
||||
const game = findWccGame(args.wccgame);
|
||||
if (!game) {
|
||||
return {
|
||||
steps: [],
|
||||
error: `Unknown WCC game: "${args.wccgame}"`,
|
||||
};
|
||||
}
|
||||
return buildFromPgn(game.pgn);
|
||||
}
|
||||
|
||||
if (args.endgame?.trim()) {
|
||||
const endgame = findEndgame(args.endgame);
|
||||
if (!endgame) {
|
||||
return {
|
||||
steps: [],
|
||||
error: `Unknown endgame: "${args.endgame}"`,
|
||||
};
|
||||
}
|
||||
const inner = buildPositions({
|
||||
fen: endgame.fen,
|
||||
moves: endgame.moves,
|
||||
});
|
||||
return {
|
||||
...inner,
|
||||
resolvedTitle: endgame.name,
|
||||
resolvedDescription: endgame.description,
|
||||
};
|
||||
}
|
||||
|
||||
const startFen = args.fen?.trim();
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
let movesSource = args.moves?.trim();
|
||||
|
||||
if (!movesSource && (args.opening || args.variation)) {
|
||||
const lookup = findOpening(args.opening, args.variation);
|
||||
if (!lookup) {
|
||||
return {
|
||||
steps: [],
|
||||
error: args.opening
|
||||
? `Unknown opening: "${args.opening}"`
|
||||
: "Variation given without an opening",
|
||||
};
|
||||
}
|
||||
movesSource = lookup.moves;
|
||||
title = lookup.variation
|
||||
? `${lookup.opening.name} — ${lookup.variation.name}`
|
||||
: lookup.opening.name;
|
||||
description =
|
||||
lookup.variation?.description ?? lookup.opening.description;
|
||||
} else if (args.opening) {
|
||||
const lookup = findOpening(args.opening, args.variation);
|
||||
if (lookup) {
|
||||
title = lookup.variation
|
||||
? `${lookup.opening.name} — ${lookup.variation.name}`
|
||||
: lookup.opening.name;
|
||||
description =
|
||||
lookup.variation?.description ?? lookup.opening.description;
|
||||
}
|
||||
}
|
||||
|
||||
let chess: Chess;
|
||||
try {
|
||||
chess = startFen ? new Chess(startFen) : new Chess();
|
||||
} catch (e) {
|
||||
return {
|
||||
steps: [],
|
||||
error: `Invalid FEN: ${(e as Error).message}`,
|
||||
resolvedTitle: title,
|
||||
resolvedDescription: description,
|
||||
};
|
||||
}
|
||||
|
||||
const steps: PositionStep[] = [{ fen: chess.fen() }];
|
||||
|
||||
if (!movesSource) {
|
||||
return {
|
||||
steps,
|
||||
resolvedTitle: title,
|
||||
resolvedDescription: description,
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenizeMoves(movesSource);
|
||||
for (const san of tokens) {
|
||||
try {
|
||||
const move = chess.move(san);
|
||||
steps.push({
|
||||
fen: chess.fen(),
|
||||
san: move.san,
|
||||
from: move.from,
|
||||
to: move.to,
|
||||
color: move.color,
|
||||
moveNumber: Math.floor((steps.length - 1) / 2) + 1,
|
||||
captured: move.captured as PieceType | undefined,
|
||||
promotion: move.promotion as PieceType | undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
steps,
|
||||
error: `Illegal move "${san}": ${(e as Error).message}`,
|
||||
resolvedTitle: title,
|
||||
resolvedDescription: description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
resolvedTitle: title,
|
||||
resolvedDescription: description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full PGN (headers + moves + optional annotations) using chess.js,
|
||||
* then walk the resulting move history to produce per-step FENs. Variations,
|
||||
* comments (`{...}` and `;...`), and NAGs (`$1`) are dropped — we follow the
|
||||
* mainline only.
|
||||
*/
|
||||
function buildFromPgn(pgn: string): BuildResult {
|
||||
const headers = extractHeaders(pgn);
|
||||
const cleaned = stripAnnotations(pgn);
|
||||
|
||||
const game = new Chess();
|
||||
try {
|
||||
game.loadPgn(cleaned, { strict: false });
|
||||
} catch (e) {
|
||||
return {
|
||||
steps: [],
|
||||
headers,
|
||||
resolvedTitle: titleFromHeaders(headers),
|
||||
resolvedDescription: descriptionFromHeaders(headers),
|
||||
error: `Could not parse PGN: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const history = game.history({ verbose: true });
|
||||
const replay = headers.fen ? new Chess(headers.fen) : new Chess();
|
||||
const steps: PositionStep[] = [{ fen: replay.fen() }];
|
||||
|
||||
for (const move of history) {
|
||||
try {
|
||||
const played = replay.move(move.san);
|
||||
steps.push({
|
||||
fen: replay.fen(),
|
||||
san: played.san,
|
||||
from: played.from,
|
||||
to: played.to,
|
||||
color: played.color,
|
||||
moveNumber: Math.floor((steps.length - 1) / 2) + 1,
|
||||
captured: played.captured as PieceType | undefined,
|
||||
promotion: played.promotion as PieceType | undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
steps,
|
||||
headers,
|
||||
resolvedTitle: titleFromHeaders(headers),
|
||||
resolvedDescription: descriptionFromHeaders(headers),
|
||||
error: `Illegal move replaying PGN: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
headers,
|
||||
resolvedTitle: titleFromHeaders(headers),
|
||||
resolvedDescription: descriptionFromHeaders(headers),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pull `[Tag "value"]` pairs out of a PGN into our typed headers shape. */
|
||||
function extractHeaders(pgn: string): PgnHeaders & { fen?: string } {
|
||||
const headers: PgnHeaders & { fen?: string } = {};
|
||||
const tagRe = /\[\s*([A-Za-z][A-Za-z0-9]*)\s+"((?:[^"\\]|\\.)*)"\s*\]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(pgn)) !== null) {
|
||||
const key = (match[1] ?? "").toLowerCase();
|
||||
const value = (match[2] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
switch (key) {
|
||||
case "event":
|
||||
headers.event = value;
|
||||
break;
|
||||
case "site":
|
||||
headers.site = value;
|
||||
break;
|
||||
case "date":
|
||||
case "utcdate":
|
||||
if (!headers.date) headers.date = value;
|
||||
break;
|
||||
case "round":
|
||||
headers.round = value;
|
||||
break;
|
||||
case "white":
|
||||
headers.white = value;
|
||||
break;
|
||||
case "black":
|
||||
headers.black = value;
|
||||
break;
|
||||
case "result":
|
||||
headers.result = value;
|
||||
break;
|
||||
case "whiteelo":
|
||||
headers.whiteElo = value;
|
||||
break;
|
||||
case "blackelo":
|
||||
headers.blackElo = value;
|
||||
break;
|
||||
case "whitetitle":
|
||||
headers.whiteTitle = value;
|
||||
break;
|
||||
case "blacktitle":
|
||||
headers.blackTitle = value;
|
||||
break;
|
||||
case "eco":
|
||||
headers.eco = value;
|
||||
break;
|
||||
case "opening":
|
||||
headers.openingName = value;
|
||||
break;
|
||||
case "timecontrol":
|
||||
headers.timeControl = value;
|
||||
break;
|
||||
case "termination":
|
||||
headers.termination = value;
|
||||
break;
|
||||
case "fen":
|
||||
headers.fen = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop PGN comments, variations, NAGs, and clock/eval annotations so the
|
||||
* tokenizer (and chess.js's loadPgn) doesn't trip over them.
|
||||
*/
|
||||
function stripAnnotations(pgn: string): string {
|
||||
return pgn
|
||||
.replace(/\{[^}]*\}/g, " ")
|
||||
.replace(/;[^\n]*/g, " ")
|
||||
.replace(/\([^)]*\)/g, " ")
|
||||
.replace(/\$\d+/g, " ");
|
||||
}
|
||||
|
||||
function titleFromHeaders(h: PgnHeaders): string | undefined {
|
||||
if (h.white && h.black) {
|
||||
return `${h.white} vs ${h.black}`;
|
||||
}
|
||||
return h.event;
|
||||
}
|
||||
|
||||
function descriptionFromHeaders(h: PgnHeaders): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (h.event) parts.push(h.event);
|
||||
if (h.date) parts.push(h.date);
|
||||
if (h.openingName) parts.push(h.openingName);
|
||||
return parts.length ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip move numbers, comments, NAGs, and result markers from a move string,
|
||||
* leaving only SAN tokens.
|
||||
*/
|
||||
function tokenizeMoves(input: string): string[] {
|
||||
const cleaned = input
|
||||
.replace(/\{[^}]*\}/g, " ")
|
||||
.replace(/\([^)]*\)/g, " ")
|
||||
.replace(/\$\d+/g, " ")
|
||||
.replace(/\b\d+\.(\.\.)?/g, " ")
|
||||
.replace(/(1-0|0-1|1\/2-1\/2|\*)\s*$/g, " ");
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
168
src/chess/explorer.ts
Normal file
168
src/chess/explorer.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import type { ExplorerSource } from "../types";
|
||||
import { USER_AGENT } from "../utils/user-agent";
|
||||
|
||||
export interface ExplorerMove {
|
||||
san: string;
|
||||
uci: string;
|
||||
white: number;
|
||||
draws: number;
|
||||
black: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ExplorerResult {
|
||||
totalGames: number;
|
||||
moves: ExplorerMove[];
|
||||
source: ExplorerSource;
|
||||
fen: string;
|
||||
}
|
||||
|
||||
const ENDPOINTS: Record<ExplorerSource, string> = {
|
||||
masters: "https://explorer.lichess.ovh/masters",
|
||||
lichess: "https://explorer.lichess.ovh/lichess",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-source LRU-ish cache of FEN -> result. Capped at 200 entries each so
|
||||
* a long study session never grows unbounded. Cache lives for the lifetime
|
||||
* of the plugin instance.
|
||||
*/
|
||||
const CACHE: Record<ExplorerSource, Map<string, ExplorerResult>> = {
|
||||
masters: new Map(),
|
||||
lichess: new Map(),
|
||||
};
|
||||
const MAX_CACHE = 200;
|
||||
|
||||
/** Tracks in-flight fetches so concurrent requesters dedupe to one network call. */
|
||||
const PENDING: Record<ExplorerSource, Map<string, Promise<ExplorerResult>>> = {
|
||||
masters: new Map(),
|
||||
lichess: new Map(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up win/draw/loss statistics for a position from the Lichess Opening
|
||||
* Explorer. Cached in memory by FEN per source. Network errors throw — let
|
||||
* the caller decide how to surface them.
|
||||
*
|
||||
* As of March 2026 the Lichess Explorer API requires authentication. Pass
|
||||
* a personal access token (created at https://lichess.org/account/oauth/token)
|
||||
* as `apiToken`.
|
||||
*/
|
||||
export async function fetchExplorer(
|
||||
fen: string,
|
||||
source: ExplorerSource,
|
||||
apiToken: string
|
||||
): Promise<ExplorerResult> {
|
||||
if (!apiToken) {
|
||||
throw new Error(
|
||||
"Add your Lichess API token in Caissa settings to use the explorer."
|
||||
);
|
||||
}
|
||||
|
||||
const cached = CACHE[source].get(fen);
|
||||
if (cached) return cached;
|
||||
|
||||
const inflight = PENDING[source].get(fen);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = doFetch(fen, source, apiToken).finally(() => {
|
||||
PENDING[source].delete(fen);
|
||||
});
|
||||
PENDING[source].set(fen, promise);
|
||||
|
||||
const result = await promise;
|
||||
rememberInCache(source, fen, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
fen: string,
|
||||
source: ExplorerSource,
|
||||
apiToken: string
|
||||
): Promise<ExplorerResult> {
|
||||
const url = `${ENDPOINTS[source]}?fen=${encodeURIComponent(fen)}&moves=20&topGames=0&recentGames=0`;
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
throw: false,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(
|
||||
"Lichess rejected your API token. Create a fresh one at lichess.org/account/oauth/token."
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error(
|
||||
"Lichess rate limit reached. Try again in a moment (limit is 25 requests per minute)."
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Lichess Explorer returned HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = response.json as {
|
||||
white?: number;
|
||||
draws?: number;
|
||||
black?: number;
|
||||
moves?: Array<{
|
||||
uci?: string;
|
||||
san?: string;
|
||||
white?: number;
|
||||
draws?: number;
|
||||
black?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const totalGames =
|
||||
(data.white ?? 0) + (data.draws ?? 0) + (data.black ?? 0);
|
||||
|
||||
const moves: ExplorerMove[] = (data.moves ?? [])
|
||||
.map((m) => {
|
||||
const white = m.white ?? 0;
|
||||
const draws = m.draws ?? 0;
|
||||
const black = m.black ?? 0;
|
||||
return {
|
||||
san: m.san ?? "",
|
||||
uci: m.uci ?? "",
|
||||
white,
|
||||
draws,
|
||||
black,
|
||||
total: white + draws + black,
|
||||
};
|
||||
})
|
||||
.filter((m) => m.san && m.total > 0);
|
||||
|
||||
return { totalGames, moves, source, fen };
|
||||
}
|
||||
|
||||
function rememberInCache(
|
||||
source: ExplorerSource,
|
||||
fen: string,
|
||||
result: ExplorerResult
|
||||
): void {
|
||||
const map = CACHE[source];
|
||||
if (map.size >= MAX_CACHE) {
|
||||
const iterator = map.keys().next();
|
||||
if (!iterator.done) {
|
||||
map.delete(iterator.value);
|
||||
}
|
||||
}
|
||||
map.set(fen, result);
|
||||
}
|
||||
|
||||
/** Clear all cached explorer results (used by tests or manual refresh). */
|
||||
export function clearExplorerCache(): void {
|
||||
CACHE.masters.clear();
|
||||
CACHE.lichess.clear();
|
||||
}
|
||||
105
src/chess/lichess-games.ts
Normal file
105
src/chess/lichess-games.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import { USER_AGENT } from "../utils/user-agent";
|
||||
|
||||
const ENDPOINT = "https://lichess.org/game/export";
|
||||
const ID_RE = /^[a-zA-Z0-9]{8,12}$/;
|
||||
|
||||
const CACHE = new Map<string, string>();
|
||||
const PENDING = new Map<string, Promise<string>>();
|
||||
const MAX_CACHE = 50;
|
||||
|
||||
/**
|
||||
* Fetch a single game's PGN from Lichess. Anonymous endpoint — no token
|
||||
* required (unlike the Opening Explorer). Cached in memory by game ID for
|
||||
* the lifetime of the plugin instance.
|
||||
*
|
||||
* Accepts either a bare game ID (`abcdefgh`) or a Lichess URL pointing at
|
||||
* the game (`https://lichess.org/abcdefgh`, with or without `/black`/`/white`
|
||||
* orientation suffix).
|
||||
*/
|
||||
export async function fetchLichessGame(idOrUrl: string): Promise<string> {
|
||||
const id = parseLichessId(idOrUrl);
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`Could not extract a Lichess game ID from "${idOrUrl}".`
|
||||
);
|
||||
}
|
||||
|
||||
const cached = CACHE.get(id);
|
||||
if (cached) return cached;
|
||||
|
||||
const inflight = PENDING.get(id);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = doFetch(id).finally(() => {
|
||||
PENDING.delete(id);
|
||||
});
|
||||
PENDING.set(id, promise);
|
||||
|
||||
const pgn = await promise;
|
||||
rememberInCache(id, pgn);
|
||||
return pgn;
|
||||
}
|
||||
|
||||
async function doFetch(id: string): Promise<string> {
|
||||
const url = `${ENDPOINT}/${id}?clocks=false&evals=false&literate=false`;
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
throw: false,
|
||||
headers: {
|
||||
Accept: "application/x-chess-pgn",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Lichess game "${id}" not found.`);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error(
|
||||
"Lichess rate limit reached. Try again in a moment."
|
||||
);
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Lichess returned HTTP ${response.status} fetching game ${id}.`
|
||||
);
|
||||
}
|
||||
|
||||
const pgn = response.text.trim();
|
||||
if (!pgn) {
|
||||
throw new Error(`Empty PGN returned for game ${id}.`);
|
||||
}
|
||||
return pgn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a Lichess game ID out of a URL or accept a bare ID. Lichess game IDs
|
||||
* are 8 alphanumeric chars; study chapter URLs use 12-char IDs which we also
|
||||
* accept (the export endpoint handles them).
|
||||
*/
|
||||
export function parseLichessId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (ID_RE.test(trimmed)) return trimmed;
|
||||
|
||||
const urlMatch =
|
||||
/^https?:\/\/(?:www\.)?lichess\.org\/(?:embed\/)?([a-zA-Z0-9]{8,12})/.exec(
|
||||
trimmed
|
||||
);
|
||||
if (urlMatch && urlMatch[1]) return urlMatch[1].slice(0, 8);
|
||||
return null;
|
||||
}
|
||||
|
||||
function rememberInCache(id: string, pgn: string): void {
|
||||
if (CACHE.size >= MAX_CACHE) {
|
||||
const iterator = CACHE.keys().next();
|
||||
if (!iterator.done) CACHE.delete(iterator.value);
|
||||
}
|
||||
CACHE.set(id, pgn);
|
||||
}
|
||||
|
||||
export function clearLichessGameCache(): void {
|
||||
CACHE.clear();
|
||||
}
|
||||
421
src/chess/openings.ts
Normal file
421
src/chess/openings.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import type { Opening, OpeningVariation } from "../types";
|
||||
|
||||
/**
|
||||
* Curated opening repertoire. Move strings are SAN (Standard Algebraic
|
||||
* Notation) without move numbers — they're fed straight into chess.js.
|
||||
*
|
||||
* Variation move strings begin from the standard start position too (i.e.
|
||||
* each variation is a complete move sequence, not a continuation), which
|
||||
* keeps lookup logic trivial.
|
||||
*/
|
||||
export const OPENINGS: Opening[] = [
|
||||
{
|
||||
name: "Italian Game",
|
||||
aliases: ["Italian"],
|
||||
moves: "e4 e5 Nf3 Nc6 Bc4",
|
||||
description:
|
||||
"Classical king-pawn opening; targets f7 and aims for quick development.",
|
||||
variations: [
|
||||
{
|
||||
name: "Giuoco Piano",
|
||||
moves: "e4 e5 Nf3 Nc6 Bc4 Bc5",
|
||||
},
|
||||
{
|
||||
name: "Giuoco Pianissimo",
|
||||
moves: "e4 e5 Nf3 Nc6 Bc4 Bc5 d3 Nf6 Nc3 d6",
|
||||
},
|
||||
{
|
||||
name: "Evans Gambit",
|
||||
moves: "e4 e5 Nf3 Nc6 Bc4 Bc5 b4",
|
||||
},
|
||||
{
|
||||
name: "Two Knights Defense",
|
||||
moves: "e4 e5 Nf3 Nc6 Bc4 Nf6",
|
||||
},
|
||||
{
|
||||
name: "Fried Liver Attack",
|
||||
moves: "e4 e5 Nf3 Nc6 Bc4 Nf6 Ng5 d5 exd5 Nxd5 Nxf7",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Ruy Lopez",
|
||||
aliases: ["Spanish", "Spanish Game"],
|
||||
moves: "e4 e5 Nf3 Nc6 Bb5",
|
||||
description:
|
||||
"One of the oldest and most respected openings; pressures the c6 knight.",
|
||||
variations: [
|
||||
{
|
||||
name: "Morphy Defense",
|
||||
moves: "e4 e5 Nf3 Nc6 Bb5 a6",
|
||||
},
|
||||
{
|
||||
name: "Berlin Defense",
|
||||
moves: "e4 e5 Nf3 Nc6 Bb5 Nf6",
|
||||
},
|
||||
{
|
||||
name: "Closed Variation",
|
||||
moves:
|
||||
"e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7 Re1 b5 Bb3 d6 c3 O-O",
|
||||
},
|
||||
{
|
||||
name: "Open Variation",
|
||||
moves: "e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Nxe4",
|
||||
},
|
||||
{
|
||||
name: "Exchange Variation",
|
||||
moves: "e4 e5 Nf3 Nc6 Bb5 a6 Bxc6 dxc6",
|
||||
},
|
||||
{
|
||||
name: "Marshall Attack",
|
||||
moves:
|
||||
"e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7 Re1 b5 Bb3 O-O c3 d5",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Sicilian Defense",
|
||||
aliases: ["Sicilian"],
|
||||
moves: "e4 c5",
|
||||
description:
|
||||
"Black's most popular reply to 1.e4 — fights for the center asymmetrically.",
|
||||
variations: [
|
||||
{
|
||||
name: "Open Sicilian",
|
||||
moves: "e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3",
|
||||
},
|
||||
{
|
||||
name: "Najdorf Variation",
|
||||
moves: "e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6",
|
||||
},
|
||||
{
|
||||
name: "Dragon Variation",
|
||||
moves: "e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6",
|
||||
},
|
||||
{
|
||||
name: "Yugoslav Attack",
|
||||
moves:
|
||||
"e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6 Be3 Bg7 f3 O-O Qd2 Nc6",
|
||||
},
|
||||
{
|
||||
name: "Accelerated Dragon",
|
||||
moves: "e4 c5 Nf3 Nc6 d4 cxd4 Nxd4 g6",
|
||||
},
|
||||
{
|
||||
name: "Scheveningen Variation",
|
||||
moves: "e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 e6",
|
||||
},
|
||||
{
|
||||
name: "Sveshnikov Variation",
|
||||
moves: "e4 c5 Nf3 Nc6 d4 cxd4 Nxd4 Nf6 Nc3 e5",
|
||||
},
|
||||
{
|
||||
name: "Taimanov Variation",
|
||||
moves: "e4 c5 Nf3 e6 d4 cxd4 Nxd4 Nc6",
|
||||
},
|
||||
{
|
||||
name: "Kan Variation",
|
||||
moves: "e4 c5 Nf3 e6 d4 cxd4 Nxd4 a6",
|
||||
},
|
||||
{
|
||||
name: "Classical Variation",
|
||||
moves: "e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 Nc6",
|
||||
},
|
||||
{
|
||||
name: "Closed Sicilian",
|
||||
moves: "e4 c5 Nc3",
|
||||
},
|
||||
{
|
||||
name: "Alapin Variation",
|
||||
moves: "e4 c5 c3",
|
||||
},
|
||||
{
|
||||
name: "Smith-Morra Gambit",
|
||||
moves: "e4 c5 d4 cxd4 c3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "French Defense",
|
||||
aliases: ["French"],
|
||||
moves: "e4 e6",
|
||||
description:
|
||||
"Solid and strategic; Black aims to undermine the e4 pawn with d5.",
|
||||
variations: [
|
||||
{
|
||||
name: "Advance Variation",
|
||||
moves: "e4 e6 d4 d5 e5",
|
||||
},
|
||||
{
|
||||
name: "Exchange Variation",
|
||||
moves: "e4 e6 d4 d5 exd5 exd5",
|
||||
},
|
||||
{
|
||||
name: "Tarrasch Variation",
|
||||
moves: "e4 e6 d4 d5 Nd2",
|
||||
},
|
||||
{
|
||||
name: "Classical Variation",
|
||||
moves: "e4 e6 d4 d5 Nc3 Nf6",
|
||||
},
|
||||
{
|
||||
name: "Winawer Variation",
|
||||
moves: "e4 e6 d4 d5 Nc3 Bb4",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Caro-Kann Defense",
|
||||
aliases: ["Caro-Kann", "Caro Kann"],
|
||||
moves: "e4 c6",
|
||||
description:
|
||||
"Reliable defense for Black; prepares d5 with a solid pawn structure.",
|
||||
variations: [
|
||||
{
|
||||
name: "Advance Variation",
|
||||
moves: "e4 c6 d4 d5 e5",
|
||||
},
|
||||
{
|
||||
name: "Exchange Variation",
|
||||
moves: "e4 c6 d4 d5 exd5 cxd5",
|
||||
},
|
||||
{
|
||||
name: "Classical Variation",
|
||||
moves: "e4 c6 d4 d5 Nc3 dxe4 Nxe4 Bf5",
|
||||
},
|
||||
{
|
||||
name: "Panov-Botvinnik Attack",
|
||||
moves: "e4 c6 d4 d5 exd5 cxd5 c4",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Scandinavian Defense",
|
||||
aliases: ["Scandinavian", "Center-Counter"],
|
||||
moves: "e4 d5",
|
||||
description: "Black challenges the center immediately with 1...d5.",
|
||||
variations: [
|
||||
{
|
||||
name: "Main Line",
|
||||
moves: "e4 d5 exd5 Qxd5 Nc3 Qa5",
|
||||
},
|
||||
{
|
||||
name: "Modern Variation",
|
||||
moves: "e4 d5 exd5 Nf6",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pirc Defense",
|
||||
aliases: ["Pirc"],
|
||||
moves: "e4 d6 d4 Nf6 Nc3 g6",
|
||||
description: "Hypermodern setup; Black fianchettos and counter-attacks.",
|
||||
variations: [
|
||||
{
|
||||
name: "Austrian Attack",
|
||||
moves: "e4 d6 d4 Nf6 Nc3 g6 f4",
|
||||
},
|
||||
{
|
||||
name: "Classical System",
|
||||
moves: "e4 d6 d4 Nf6 Nc3 g6 Nf3 Bg7 Be2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Alekhine's Defense",
|
||||
aliases: ["Alekhine"],
|
||||
moves: "e4 Nf6",
|
||||
description:
|
||||
"Provocative — invites White to push pawns and over-extend.",
|
||||
variations: [
|
||||
{
|
||||
name: "Modern Variation",
|
||||
moves: "e4 Nf6 e5 Nd5 d4 d6 Nf3",
|
||||
},
|
||||
{
|
||||
name: "Four Pawns Attack",
|
||||
moves: "e4 Nf6 e5 Nd5 d4 d6 c4 Nb6 f4",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Queen's Gambit",
|
||||
aliases: ["QG"],
|
||||
moves: "d4 d5 c4",
|
||||
description:
|
||||
"Central pawn lever — White offers the c-pawn for control of the center.",
|
||||
variations: [
|
||||
{
|
||||
name: "Queen's Gambit Accepted",
|
||||
moves: "d4 d5 c4 dxc4",
|
||||
},
|
||||
{
|
||||
name: "Queen's Gambit Declined",
|
||||
moves: "d4 d5 c4 e6",
|
||||
},
|
||||
{
|
||||
name: "Slav Defense",
|
||||
moves: "d4 d5 c4 c6",
|
||||
},
|
||||
{
|
||||
name: "Semi-Slav Defense",
|
||||
moves: "d4 d5 c4 c6 Nc3 Nf6 Nf3 e6",
|
||||
},
|
||||
{
|
||||
name: "Tarrasch Defense",
|
||||
moves: "d4 d5 c4 e6 Nc3 c5",
|
||||
},
|
||||
{
|
||||
name: "Albin Counter-Gambit",
|
||||
moves: "d4 d5 c4 e5",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "King's Indian Defense",
|
||||
aliases: ["KID", "Kings Indian"],
|
||||
moves: "d4 Nf6 c4 g6",
|
||||
description:
|
||||
"Hypermodern defense; Black allows a big center then strikes back.",
|
||||
variations: [
|
||||
{
|
||||
name: "Classical Variation",
|
||||
moves: "d4 Nf6 c4 g6 Nc3 Bg7 e4 d6 Nf3 O-O Be2 e5",
|
||||
},
|
||||
{
|
||||
name: "Sämisch Variation",
|
||||
moves: "d4 Nf6 c4 g6 Nc3 Bg7 e4 d6 f3",
|
||||
},
|
||||
{
|
||||
name: "Fianchetto Variation",
|
||||
moves: "d4 Nf6 c4 g6 Nc3 Bg7 g3",
|
||||
},
|
||||
{
|
||||
name: "Four Pawns Attack",
|
||||
moves: "d4 Nf6 c4 g6 Nc3 Bg7 e4 d6 f4",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Nimzo-Indian Defense",
|
||||
aliases: ["Nimzo-Indian", "Nimzo Indian"],
|
||||
moves: "d4 Nf6 c4 e6 Nc3 Bb4",
|
||||
description: "Pin the c3 knight; one of the soundest defenses to 1.d4.",
|
||||
variations: [
|
||||
{
|
||||
name: "Rubinstein System",
|
||||
moves: "d4 Nf6 c4 e6 Nc3 Bb4 e3",
|
||||
},
|
||||
{
|
||||
name: "Classical Variation",
|
||||
moves: "d4 Nf6 c4 e6 Nc3 Bb4 Qc2",
|
||||
},
|
||||
{
|
||||
name: "Sämisch Variation",
|
||||
moves: "d4 Nf6 c4 e6 Nc3 Bb4 a3 Bxc3+ bxc3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Grünfeld Defense",
|
||||
aliases: ["Grunfeld"],
|
||||
moves: "d4 Nf6 c4 g6 Nc3 d5",
|
||||
description:
|
||||
"Hypermodern — Black challenges the center directly with d5.",
|
||||
variations: [
|
||||
{
|
||||
name: "Exchange Variation",
|
||||
moves: "d4 Nf6 c4 g6 Nc3 d5 cxd5 Nxd5 e4 Nxc3 bxc3",
|
||||
},
|
||||
{
|
||||
name: "Russian System",
|
||||
moves: "d4 Nf6 c4 g6 Nc3 d5 Nf3 Bg7 Qb3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "English Opening",
|
||||
aliases: ["English"],
|
||||
moves: "c4",
|
||||
description: "Flank opening; controls d5 from the side.",
|
||||
variations: [
|
||||
{
|
||||
name: "Symmetrical Variation",
|
||||
moves: "c4 c5",
|
||||
},
|
||||
{
|
||||
name: "Reversed Sicilian",
|
||||
moves: "c4 e5",
|
||||
},
|
||||
{
|
||||
name: "Anglo-Indian Defense",
|
||||
moves: "c4 Nf6",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Reti Opening",
|
||||
aliases: ["Réti"],
|
||||
moves: "Nf3",
|
||||
description: "Flexible flank opening; can transpose to many systems.",
|
||||
variations: [
|
||||
{
|
||||
name: "King's Indian Attack",
|
||||
moves: "Nf3 d5 g3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "London System",
|
||||
aliases: ["London"],
|
||||
moves: "d4 d5 Nf3 Nf6 Bf4",
|
||||
description:
|
||||
"Solid system for White; develop pieces to standard squares.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Normalize a name for fuzzy lookup. */
|
||||
function normalize(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
}
|
||||
|
||||
export interface OpeningLookupResult {
|
||||
opening: Opening;
|
||||
variation?: OpeningVariation;
|
||||
moves: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an opening (and optional variation) by name. Case-insensitive,
|
||||
* tolerant of punctuation, accepts aliases. Returns the move sequence
|
||||
* to play out from the start position.
|
||||
*/
|
||||
export function findOpening(
|
||||
openingName: string | undefined,
|
||||
variationName?: string
|
||||
): OpeningLookupResult | null {
|
||||
if (!openingName) {
|
||||
return null;
|
||||
}
|
||||
const target = normalize(openingName);
|
||||
const opening = OPENINGS.find((o) => {
|
||||
if (normalize(o.name) === target) return true;
|
||||
if (o.aliases?.some((a) => normalize(a) === target)) return true;
|
||||
return false;
|
||||
});
|
||||
if (!opening) {
|
||||
return null;
|
||||
}
|
||||
if (!variationName) {
|
||||
return { opening, moves: opening.moves };
|
||||
}
|
||||
const vTarget = normalize(variationName);
|
||||
const variation = opening.variations?.find(
|
||||
(v) => normalize(v.name) === vTarget
|
||||
);
|
||||
if (!variation) {
|
||||
return { opening, moves: opening.moves };
|
||||
}
|
||||
return { opening, variation, moves: variation.moves };
|
||||
}
|
||||
|
||||
101
src/chess/piece-data.generated.ts
Normal file
101
src/chess/piece-data.generated.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// AUTO-GENERATED by scripts/fetch-piece-sets.mjs — do not edit by hand.
|
||||
// Source: https://github.com/lichess-org/lila/tree/master/public/piece
|
||||
// Re-run with: node scripts/fetch-piece-sets.mjs
|
||||
|
||||
import type { PieceKey } from "./pieces-types";
|
||||
|
||||
export interface PieceSetData {
|
||||
license: string;
|
||||
vbX: number;
|
||||
vbY: number;
|
||||
vbW: number;
|
||||
vbH: number;
|
||||
pieces: Record<PieceKey, string>;
|
||||
}
|
||||
|
||||
export const PIECE_SETS: Record<string, PieceSetData> = {
|
||||
merida: {
|
||||
license: "GPLv2+ (Armando Hernandez Marroquin)",
|
||||
vbX: 0,
|
||||
vbY: 0,
|
||||
vbW: 50,
|
||||
vbH: 50,
|
||||
pieces: {
|
||||
wk: `<linearGradient id="a" x1="21.376" x2="77.641" y1="37.346" y2="37.346" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M25.821 12.022h-1.76v-3.25h-2.067c-.558 0-.838-.272-.838-.822v-.025c0-.542.28-.813.838-.813h2.066V5.004c0-.585.297-.872.89-.872.575 0 .871.287.871.872v2.108h2.134c.542 0 .813.27.813.813v.025c0 .55-.271.821-.813.821l-2.117.026zM11.03 37.744l-.813-4.64c-.017 0-.042-.033-.076-.101-.085-.119-.322-.271-.711-.457-.381-.195-.838-.517-1.346-.982a41.99 41.99 0 0 1-1.702-1.49 8.509 8.509 0 0 1-1.1-1.237C4.273 27.45 3.705 25.772 3.595 23.8c-.17-1.897.601-3.794 2.303-5.682 1.719-1.88 4.047-2.768 6.968-2.65 1.092.068 2.38.33 3.844.796.483.195.974.39 1.482.576l1.498.584c.263.136.5.271.695.398a4.38 4.38 0 0 1-.127-1.041c0-1.287.457-2.388 1.38-3.302.914-.906 2.023-1.372 3.31-1.389 1.287 0 2.388.466 3.302 1.38.906.915 1.363 2.015 1.363 3.285 0 .263-.034.61-.101 1.042.228-.144.457-.271.669-.373.762-.33 1.76-.72 3.005-1.16 1.423-.482 2.701-.753 3.844-.821 2.921-.136 5.241.753 6.943 2.65 1.668 1.888 2.447 3.785 2.328 5.681-.127 1.973-.703 3.65-1.71 5.038-.33.449-.703.863-1.118 1.253a40.5 40.5 0 0 1-1.66 1.473c-.541.466-1.007.796-1.388.982-.38.186-.6.347-.669.457a.294.294 0 0 1-.05.077c-.017.017-.026.034-.026.05l-.796 4.666 1.643 6.121c-.83.745-2.684 1.355-5.554 1.837-2.879.483-6.206.72-9.974.72-3.835 0-7.214-.254-10.118-.754-2.912-.508-4.741-1.143-5.486-1.896z"/><path fill="url(#a)" d="M25.796 29.532c2.845.033 5.444.203 7.806.508 2.37.304 4.225.694 5.563 1.151a126.32 126.32 0 0 0 2.057-1.651 12.018 12.018 0 0 0 1.863-1.846c.787-1.007 1.185-2.337 1.185-3.996 0-1.482-.356-2.726-1.067-3.717-1.27-1.854-3.209-2.777-5.8-2.777-1.557 0-3.149.322-4.792.965-1.439.584-2.531 1.228-3.268 1.94-1.388 1.388-2.421 3.174-3.082 5.35-.228.779-.364 1.49-.406 2.125s-.06 1.287-.06 1.947zm-13.25 6.697c3.14-.796 7.306-1.194 12.505-1.194 5.088 0 9.203.38 12.327 1.143l.618-3.65c-3.327-.871-7.67-1.312-13.047-1.312-5.41 0-9.745.45-13.022 1.338zm25.298 4.41-.737-2.844c-3.276-.728-7.332-1.092-12.158-1.092-4.809 0-8.856.364-12.133 1.092l-.787 2.87c3.158-.923 7.468-1.388 12.946-1.388 5.444 0 9.728.457 12.869 1.363zm.652 2.338c-3.192-1.287-7.68-1.94-13.445-1.94-5.986 0-10.516.661-13.598 1.99 2.913 1.152 7.417 1.736 13.522 1.736 2.912 0 5.562-.16 7.958-.483 2.405-.321 4.25-.762 5.563-1.303M24.077 29.532c-.008-.644-.034-1.287-.068-1.922s-.16-1.347-.372-2.126c-.677-2.21-1.702-3.996-3.082-5.35-.711-.695-1.795-1.347-3.268-1.94-1.685-.66-3.285-.99-4.792-.99-2.608 0-4.547.931-5.8 2.803-.711.99-1.067 2.235-1.067 3.716 0 1.626.398 2.955 1.186 3.997.482.61 1.092 1.227 1.837 1.837s1.44 1.168 2.083 1.66c2.895-1.042 7.34-1.6 13.343-1.685m.872-4.615c.119-.465.212-.787.296-.965.17-.643.356-1.194.576-1.643.093-.279.237-.6.432-.973.186-.373.39-.805.61-1.279.127-.28.27-.626.415-1.033.152-.406.304-.804.448-1.202.136-.33.203-.686.203-1.067 0-.813-.296-1.498-.872-2.066-.575-.575-1.278-.863-2.108-.863-1.964 0-2.955.99-2.955 2.955 0 .38.068.736.203 1.067.365 1.075.644 1.82.839 2.235.22.474.415.906.6 1.278.179.373.34.694.466.974.22.55.398 1.092.55 1.642.035.094.128.415.297.94"/>`,
|
||||
wq: `<linearGradient id="a" x1="21.253" x2="77.641" y1="37.224" y2="37.346" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" stroke="#1f1a17" stroke-width=".076" d="M44.541 14.723c-.94 0-1.744-.33-2.404-.982s-.991-1.448-.991-2.396q0-1.3845.99-2.388c.66-.677 1.465-1.007 2.405-1.007.931 0 1.727.33 2.388 1.007.66.67.99 1.465.99 2.388 0 .948-.33 1.744-.99 2.396a3.28 3.28 0 0 1-2.388.982zm-4.31 29.219c-.812.71-2.633 1.304-5.46 1.786-2.828.474-6.088.72-9.771.72-3.75 0-7.053-.254-9.898-.745-2.844-.5-4.64-1.118-5.384-1.863l1.566-5.952-.694-3.895L8.405 30.2 6.297 14.774l1.21-.474 6.8 11.455.152-13.64 1.685-.296 5.182 13.716 2.776-14.757h1.72l2.776 14.706L33.73 11.82l1.71.296.153 13.64 6.824-11.48 1.16.541-2.058 15.359-2.21 3.793-.694 3.945zM14.535 11.988c-.948 0-1.752-.321-2.413-.973-.66-.652-.99-1.456-.99-2.396 0-.923.33-1.719.99-2.38s1.465-.99 2.413-.99c.923 0 1.719.33 2.38.99s.99 1.457.99 2.38c0 .94-.33 1.744-.99 2.396a3.266 3.266 0 0 1-2.38.973zM5.4 14.723c-.94 0-1.736-.33-2.388-.982s-.982-1.448-.982-2.396c0-.923.33-1.719.982-2.388C3.664 8.28 4.46 7.95 5.4 7.95c.948 0 1.744.33 2.413 1.007.66.67.99 1.465.99 2.388 0 .948-.33 1.744-.99 2.396a3.323 3.323 0 0 1-2.413.982zm19.55-3.97c-.94 0-1.745-.33-2.397-.991-.652-.66-.974-1.465-.974-2.405 0-.931.322-1.727.974-2.387s1.456-.99 2.396-.99c.923 0 1.727.33 2.396.99a3.23 3.23 0 0 1 1 2.387c0 .94-.33 1.744-1 2.405-.669.66-1.473.99-2.396.99zm10.413 1.235c-.94 0-1.736-.321-2.387-.973-.652-.652-.983-1.456-.983-2.396 0-.923.33-1.719.983-2.38s1.447-.99 2.387-.99c.948 0 1.753.33 2.413.99s.99 1.457.99 2.38c0 .94-.33 1.744-.99 2.396s-1.465.973-2.413.973z"/><path fill="url(#a)" stroke="#1f1a17" stroke-width=".076" d="M38.217 43.044c-3.023-1.253-7.417-1.88-13.166-1.88-5.876 0-10.313.644-13.327 1.931 2.896 1.143 7.316 1.71 13.25 1.71 2.845 0 5.445-.152 7.798-.465 2.363-.314 4.175-.745 5.445-1.296zM24.949 9.017c1.11 0 1.66-.56 1.66-1.66 0-1.092-.55-1.642-1.66-1.642-1.092 0-1.634.55-1.634 1.642 0 1.1.542 1.66 1.634 1.66zm12.624 24.976c-3.192-.812-7.366-1.21-12.522-1.21-5.292 0-9.517.406-12.675 1.236l.373 2.379c3.217-.762 7.323-1.143 12.302-1.143 4.944 0 8.975.372 12.099 1.117zm.618-1.49 1.617-2.853a6.432 6.432 0 0 1-2.43.474c-2.218 0-3.987-.897-5.308-2.7-.99.82-2.1 1.235-3.328 1.235-1.583 0-2.853-.618-3.793-1.862-1.058 1.16-2.32 1.744-3.793 1.744-1.194 0-2.286-.406-3.276-1.22-1.389 1.77-3.184 2.65-5.385 2.65a7.055 7.055 0 0 1-2.506-.465l1.735 2.972c3.21-.923 7.62-1.389 13.225-1.389 5.707 0 10.118.474 13.242 1.414zm-11.108-5.926-2.108-12.133-2.109 11.989c.051-.034.161-.119.348-.254.38-.745.956-1.118 1.735-1.118.847 0 1.389.373 1.634 1.118.102.101.271.237.5.398zm6.866.474V15.56l-4.09 11.261c.314-.11.577-.262.797-.44.33-.415.779-.627 1.338-.627.66 0 1.193.297 1.591.872.043.068.102.136.17.212.067.076.135.144.194.212zm-13.936-.347L15.95 15.562v11.336c.043-.067.119-.144.22-.245.33-.694.872-1.042 1.634-1.042.627 0 1.143.263 1.541.796.449.195.67.297.67.297zm-6.3 1.388L8.38 18.89l1.363 8.382c.94.66 1.863.99 2.752.99.347 0 .753-.059 1.219-.169zm22.395.119c.381.118.805.178 1.27.178 1.008 0 1.948-.314 2.828-.94l1.363-8.585zm1.49 12.556-.745-2.803c-3.242-.71-7.205-1.066-11.904-1.066-4.648 0-8.61.355-11.878 1.066l-.771 2.828c3.073-.931 7.298-1.388 12.675-1.388 5.24 0 9.448.448 12.623 1.363zM14.535 10.253c1.084 0 1.634-.542 1.634-1.634s-.55-1.634-1.634-1.634c-1.109 0-1.668.542-1.668 1.634s.56 1.634 1.668 1.634zm20.828 0c1.11 0 1.668-.542 1.668-1.634s-.559-1.634-1.668-1.634c-1.083 0-1.634.542-1.634 1.634s.55 1.634 1.634 1.634zM5.4 12.988c1.109 0 1.668-.55 1.668-1.643 0-1.11-.56-1.66-1.668-1.66-1.084 0-1.634.55-1.634 1.66 0 1.092.55 1.643 1.634 1.643zm39.141 0c1.092 0 1.643-.55 1.643-1.643 0-1.11-.55-1.66-1.643-1.66-1.1 0-1.66.55-1.66 1.66 0 1.092.56 1.643 1.66 1.643z"/>`,
|
||||
wr: `<linearGradient id="a" x1="21.376" x2="77.641" y1="37.469" y2="37.469" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M28.408 9.22h4.216V5.825h6.799v9.296l-5.503 4.242v11.862l4.216 4.216v5.08h3.793v5.927H8.071V40.52h3.793v-5.08l4.242-4.216V19.363l-5.504-4.242V5.825h6.774V9.22h4.242V5.825h6.79z"/><path fill="url(#a)" d="m33.073 17.678 3.15-2.557h-22.42l3.175 2.557zm7.197 24.528H9.756v2.557H40.27zm-3.844-5.055H13.6v3.37h22.826zm-4.217-17.788H17.816v11.862h14.393zm5.504-5.927V7.51h-3.395v3.395h-7.646V7.51h-3.344v3.395h-7.62V7.51h-3.395v5.926zm-1.914 22.005-2.548-2.531H16.8l-2.6 2.531z"/>`,
|
||||
wb: `<linearGradient id="a" x1="21.13" x2="77.641" y1="37.592" y2="37.469" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M25.447 42.008c-.228.94-.516 1.592-.846 1.956s-.762.745-1.313 1.143c-.592.415-1.295.762-2.108 1.05s-1.71.364-2.7.211l-6.969-.965a2.858 2.858 0 0 0-.762 0c-.22.034-.432.051-.635.051-.347 0-.787.076-1.32.237-.542.152-.957.381-1.254.677l-2.404-3.945c.296-.33.559-.559.787-.694.237-.127.508-.271.821-.415a9.179 9.179 0 0 1 3.074-.822c.466-.033.923-.042 1.363-.025a9.8 9.8 0 0 0 1.397-.05c.89.152 1.786.287 2.684.406.906.127 1.812.254 2.718.39.99 0 1.66-.102 2.006-.297.187-.102.474-.288.872-.55.398-.263.796-.652 1.194-1.169-.88-.093-1.77-.262-2.684-.508a24.094 24.094 0 0 1-2.404-.753l2.582-6.401c-1.295-.745-2.193-1.338-2.71-1.795a5.3 5.3 0 0 1-1.21-1.575c-.432-.762-.711-1.499-.83-2.21a9.341 9.341 0 0 1-.16-1.913c.016-.99.245-2.083.702-3.285.457-1.194 1.312-2.27 2.566-3.21a79.091 79.091 0 0 0 3.056-2.455 27.746 27.746 0 0 0 2.946-2.954c-1.219-.627-1.828-1.626-1.828-2.998 0-.93.321-1.718.973-2.387.652-.66 1.457-.99 2.396-.99.923 0 1.72.33 2.38.99.66.669.99 1.456.99 2.387 0 1.355-.61 2.354-1.829 2.998a26.796 26.796 0 0 0 2.913 2.954c.982.839 2.015 1.66 3.09 2.456 1.236.94 2.083 2.015 2.523 3.209.449 1.202.694 2.294.72 3.285 0 .567-.05 1.202-.17 1.913s-.38 1.448-.795 2.21a6.084 6.084 0 0 1-1.253 1.575c-.5.457-1.389 1.05-2.667 1.795l2.582 6.4a28.57 28.57 0 0 1-2.455.754c-.915.246-1.787.415-2.634.508.381.517.771.906 1.169 1.168.398.263.694.45.897.55.347.196 1.016.297 2.007.297a263.35 263.35 0 0 1 2.692-.39 81.13 81.13 0 0 0 2.718-.406c.44.051.89.068 1.346.051a13.12 13.12 0 0 1 1.406.025 9.627 9.627 0 0 1 3.073.822c.297.144.567.288.805.415.245.135.508.364.804.694l-2.43 3.945c-.296-.296-.711-.525-1.253-.677-.534-.16-.965-.237-1.296-.237-.22 0-.44-.017-.66-.05a2.794 2.794 0 0 0-.753 0l-6.952.964c-.99.153-1.913.085-2.76-.194-.855-.28-1.558-.652-2.1-1.118a20.04 20.04 0 0 1-1.303-1.151c-.322-.322-.593-.957-.805-1.897"/><path fill="url(#a)" d="M26.32 39.197c0 1.092.245 2.024.753 2.794.5.77 1.041 1.372 1.626 1.795.905.669 2.235 1 3.987 1 .432 0 1.279-.094 2.532-.28a74.737 74.737 0 0 1 2.48-.356c.627-.076 1.05-.135 1.27-.186a6.53 6.53 0 0 1 1.982.05c.262.068.559.128.88.187a1.6 1.6 0 0 1 .805.38l1.194-1.93a7.372 7.372 0 0 0-2.16-.72c-1.252-.22-2.353-.262-3.301-.151-.28.033-.644.118-1.101.245-.457.136-1.067.263-1.846.372-1.676.272-2.557.399-2.658.399-.644 0-1.203-.077-1.685-.246a10.37 10.37 0 0 1-1.287-.542c-.88-.398-1.77-1.338-2.684-2.81zm-1.762 0h-.795c-.932 1.49-1.812 2.43-2.659 2.811-.398.195-.83.373-1.312.542-.483.17-1.033.246-1.66.246-.118 0-.999-.127-2.658-.398-.788-.11-1.423-.238-1.88-.373a8.828 8.828 0 0 0-1.092-.245c-.948-.11-2.04-.068-3.302.152a7.056 7.056 0 0 0-2.134.72l1.194 1.93c.195-.195.457-.322.779-.381.322-.06.618-.119.88-.186a6.53 6.53 0 0 1 1.982-.051c.22.05.643.11 1.27.186.626.076 1.465.195 2.506.356 1.236.186 2.083.28 2.531.28 1.736 0 3.065-.331 3.988-1 .567-.423 1.1-1.024 1.6-1.795.508-.77.762-1.702.762-2.794m.89-9.347c1.6 0 3.14.127 4.614.372 1.617-.575 2.794-1.481 3.522-2.7a6.745 6.745 0 0 0 .94-3.497c0-.762-.187-1.6-.568-2.523-.38-.915-.999-1.744-1.862-2.49-.974-.812-2.04-1.701-3.2-2.666a33.093 33.093 0 0 1-3.447-3.387c-1.16 1.287-2.311 2.421-3.47 3.387a406.55 406.55 0 0 0-3.176 2.667c-.88.745-1.499 1.574-1.871 2.489-.373.923-.559 1.76-.559 2.523 0 1.27.305 2.438.914 3.497.712 1.219 1.897 2.125 3.548 2.7a27.749 27.749 0 0 1 4.614-.372zm0 4.513c1.938 0 3.793.194 5.579.575l-1.185-3.056a28.297 28.297 0 0 0-4.395-.347c-1.507 0-2.98.118-4.41.347l-1.194 3.056c1.769-.38 3.64-.575 5.604-.575zm0-23.538c1.126 0 1.684-.559 1.684-1.685s-.559-1.693-1.685-1.693-1.684.567-1.684 1.693.558 1.685 1.684 1.685zm0 27.009a18.97 18.97 0 0 0 3.285-.28c1.066-.194 2.1-.423 3.09-.685-1.94-.508-4.064-.77-6.376-.77-2.345 0-4.47.262-6.375.77.957.262 1.973.49 3.048.686a19.46 19.46 0 0 0 3.327.279zm-.89-14.334-2.065-.026c-.56 0-.839-.279-.839-.846 0-.559.28-.838.839-.838h2.065v-2.134c0-.576.297-.872.89-.872.575 0 .872.296.872.872v2.134h2.133c.542 0 .813.28.813.838 0 .567-.271.846-.813.846H26.32v2.032c0 .602-.297.898-.873.898-.592 0-.889-.296-.889-.898z"/>`,
|
||||
wn: `<linearGradient id="a" x1="21.405" x2="77.641" y1="37.346" y2="37.346" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M26.178 9.395c2.6.17 5.004.838 7.222 2.015 2.21 1.169 4.098 2.676 5.656 4.513 1.092 1.287 2.117 2.845 3.082 4.665a28.684 28.684 0 0 1 2.32 5.774 36.511 36.511 0 0 1 1.253 7.46c.177 2.599.262 5.012.262 7.23v5.402H15.468c-.153 0-.22-.407-.212-1.21.009-.814.06-1.466.16-1.965.06-.398.221-.957.467-1.685.254-.728.66-1.609 1.244-2.65.263-.534.89-1.304 1.88-2.32.999-1.016 2.133-2.201 3.429-3.539.745-.762 1.32-1.719 1.744-2.879.423-1.151.601-2.201.533-3.15a8.37 8.37 0 0 1-2.006 1.22c-3.505 1.253-6.045 3.073-7.612 5.452-.118.153-.49.822-1.117 2.015-.33.627-.618 1.059-.847 1.287-.313.314-.77.491-1.363.525-.923.043-1.643-.398-2.16-1.346-.693.203-1.312.288-1.862.254-.923-.347-1.592-.72-2.006-1.117-.847-.847-1.389-1.685-1.651-2.532a9.43 9.43 0 0 1-.381-2.726c0-1.389.855-3.226 2.582-5.512 2.015-2.625 3.09-4.631 3.217-6.003 0-.593.06-1.261.178-2.007a4.198 4.198 0 0 1 .618-1.49c.22-.33.364-.558.432-.677.076-.127.212-.313.415-.559.144-.203.27-.355.372-.457.093-.11.22-.254.373-.44.178-.212.406-.457.694-.745a18.06 18.06 0 0 1-1.067-7.46c3.285 1.169 6.054 3.015 8.28 5.53.551-1.872 1.626-3.387 3.226-4.539 1.321.923 2.371 2.15 3.15 3.666"/><path fill="url(#a)" d="M42.976 44.693c-.017 0 0-.449.042-1.346.051-.906.076-1.88.076-2.921.017-2.066.017-4.2 0-6.41a26.837 26.837 0 0 0-.889-6.612c-.567-2.117-1.185-3.92-1.862-5.419-.678-1.498-1.414-2.785-2.21-3.878-1.185-1.786-2.811-3.302-4.86-4.538-2.049-1.244-4.19-2.057-6.426-2.438.152.813.22 1.609.203 2.387-.034.593-.313.89-.847.89-.61 0-.88-.297-.82-.89.05-2.184-.729-4.055-2.33-5.604-1.252 1.32-1.938 2.853-2.031 4.605-.034.585-.33.839-.898.77-.525-.016-.787-.32-.787-.914 0 0 .017-.067.042-.203-.677.22-1.388.525-2.133.923-.474.33-.864.246-1.16-.245-.297-.5-.17-.89.398-1.169.71-.364 1.244-.635 1.608-.821a17.634 17.634 0 0 0-4.86-3.522 17.31 17.31 0 0 0 1.889 6.528c.279.423.211.804-.204 1.134-.465.364-.855.313-1.168-.17a8.87 8.87 0 0 1-.491-.897c-.347.347-.584.61-.694.77-.119.153-.322.483-.61.991-.288.517-.5.94-.635 1.27-.144.415-.212.745-.186 1.008.025.254.05.533.067.855a7.61 7.61 0 0 1-1.007 2.752 133.71 133.71 0 0 1-1.998 3.15 127.607 127.607 0 0 1-1.787 2.675c-.415.601-.728 1.354-.94 2.286-.152.559-.152 1.244 0 2.04.144.805.475 1.431.966 1.88.762.77 1.498 1.126 2.21 1.067.228 0 .541-.093.93-.28.39-.178.687-.525.907-1.041.423-.94.779-1.414 1.067-1.414.406 0 .635.237.668.694 0 .102-.135.517-.397 1.245-.153.33-.348.677-.593 1.041-.322.432-.457.61-.423.542.262.948.702 1.11 1.312.5.178-.178.39-.525.618-1.016q.3555-.75 1.092-2.007c.584-.982 1.202-1.77 1.863-2.388.66-.61 1.244-1.109 1.76-1.481.297-.22.661-.466 1.093-.745.432-.288 1.008-.576 1.736-.872.576-.229 1.219-.517 1.922-.856s1.329-.77 1.87-1.303c.763-.745 1.347-1.66 1.762-2.752.22-.61.296-1.363.245-2.26-.144-.56.136-.839.847-.839.533 0 .83.271.898.821 0 1.863-.534 3.565-1.592 5.106.347 1.058.44 2.218.27 3.471-.143 1.008-.499 2.091-1.05 3.243-.558 1.143-1.676 2.421-3.36 3.827-3.43 2.845-5.046 5.774-4.86 8.78h12.175zM9.338 29.613c-.483.297-.77.695-.872 1.194.017.542-.237.839-.762.89-.584.067-.88-.178-.898-.746.068-1.092.55-1.955 1.465-2.599.432-.347.83-.322 1.194.093.364.449.322.838-.127 1.169zm7.366-11.827c.212.33.296.677.245 1.041-.16 1.058-.753 1.499-1.76 1.338a1.596 1.596 0 0 1-.72-.296c-.06.076-.161.262-.297.541-.178.534-.525.712-1.041.55-.508-.202-.711-.575-.593-1.117.745-1.905 2.091-3.209 4.039-3.92.567-.17.94 0 1.117.491.204.534.051.898-.448 1.092a2.745 2.745 0 0 1-.271.136c-.085.042-.17.093-.271.144"/>`,
|
||||
wp: `<linearGradient id="a" x1="21.13" x2="77.764" y1="37.346" y2="37.469" gradientTransform="matrix(1 0 0 .97324 0 1.243)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M25 46.448H11.606a13.139 13.139 0 0 1-.99-5.043c0-2.975.863-5.644 2.598-8.018 1.736-2.365 3.971-4.054 6.697-5.067a6.824 6.824 0 0 1-2.861-2.398c-.737-1.071-1.1-2.283-1.1-3.634 0-1.69.575-3.156 1.735-4.392 1.151-1.244 2.574-1.961 4.267-2.15-1.346-.981-2.015-2.283-2.015-3.89 0-1.351.491-2.513 1.482-3.477.982-.964 2.176-1.442 3.581-1.442 1.389 0 2.582.478 3.573 1.442.99.964 1.49 2.126 1.49 3.477 0 1.607-.669 2.909-2.015 3.89 1.693.189 3.116.906 4.267 2.15 1.16 1.236 1.736 2.703 1.736 4.392 0 1.351-.373 2.563-1.126 3.634a7.036 7.036 0 0 1-2.862 2.398c2.726 1.013 4.962 2.702 6.697 5.067 1.736 2.374 2.6 5.043 2.6 8.018q0 2.6085-.966 5.043z"/><path fill="url(#a)" d="M25 44.808h12.175a11.79 11.79 0 0 0 .525-3.403c0-2.513-.711-4.787-2.142-6.831-1.43-2.044-3.277-3.552-5.52-4.516-1.584-.62-1.643-.659-1.643-1.738 0-.849.559-1.475 1.668-1.879 1.533-1.046 2.303-2.43 2.303-4.153 0-1.244-.432-2.324-1.287-3.263-.864-.931-1.905-1.467-3.124-1.615-1-.083-1.49-.626-1.49-1.64 0-.453.178-.873.542-1.26.897-.676 1.346-1.558 1.346-2.654 0-.898-.339-1.673-1-2.315-.66-.643-1.447-.964-2.353-.964-.94 0-1.744.32-2.396.964a3.136 3.136 0 0 0-.974 2.315c0 1.08.44 1.961 1.338 2.653.364.355.542.775.542 1.261 0 1.014-.483 1.557-1.465 1.64a4.9 4.9 0 0 0-3.133 1.615c-.855.94-1.278 2.019-1.278 3.263 0 1.722.77 3.107 2.303 4.153 1.11.412 1.668 1.046 1.668 1.879 0 1.08-.068 1.118-1.668 1.738-2.244.964-4.081 2.472-5.503 4.516-1.423 2.044-2.134 4.318-2.134 6.831 0 1.195.178 2.324.525 3.403z"/>`,
|
||||
bk: `<linearGradient id="a" x1="21.13" x2="77.764" y1="37.224" y2="37.469" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M25.821 12.022h-1.76v-3.25h-2.067c-.558 0-.838-.272-.838-.822v-.025c0-.542.28-.813.838-.813h2.066V5.004c0-.585.297-.872.89-.872.575 0 .871.287.871.872v2.108h2.134c.542 0 .813.27.813.813v.025c0 .55-.271.821-.813.821l-2.117.026zM11.03 37.744l-.813-4.64c-.017 0-.042-.033-.076-.101-.085-.119-.322-.271-.711-.457-.381-.195-.838-.517-1.346-.982a41.99 41.99 0 0 1-1.702-1.49 8.509 8.509 0 0 1-1.1-1.237C4.273 27.45 3.705 25.772 3.595 23.8c-.17-1.897.601-3.794 2.303-5.682 1.719-1.88 4.047-2.768 6.968-2.65 1.092.068 2.38.33 3.844.796.483.195.974.39 1.482.576l1.498.584c.263.136.5.271.695.398a4.38 4.38 0 0 1-.127-1.041c0-1.287.457-2.388 1.38-3.302.914-.906 2.023-1.372 3.31-1.389 1.287 0 2.388.466 3.302 1.38.906.915 1.363 2.015 1.363 3.285 0 .263-.034.61-.101 1.042.228-.144.457-.271.669-.373.762-.33 1.76-.72 3.005-1.16 1.423-.482 2.701-.753 3.844-.821 2.921-.136 5.241.753 6.943 2.65 1.668 1.888 2.447 3.785 2.328 5.681-.127 1.973-.703 3.65-1.71 5.038-.33.449-.703.863-1.118 1.253a40.5 40.5 0 0 1-1.66 1.473c-.541.466-1.007.796-1.388.982-.38.186-.6.347-.669.457a.294.294 0 0 1-.05.077c-.017.017-.026.034-.026.05l-.796 4.666 1.643 6.121c-.83.745-2.684 1.355-5.554 1.837-2.879.483-6.206.72-9.974.72-3.835 0-7.214-.254-10.118-.754-2.912-.508-4.741-1.143-5.486-1.896z"/><path fill="url(#a)" d="M24.95 20.675a2.295 2.295 0 0 0-.128-.423 5.606 5.606 0 0 0-.245-.72c-.051-.11-.119-.254-.195-.431a9.028 9.028 0 0 1-.254-.56c-.05-.118-.11-.27-.186-.456-.068-.195-.136-.373-.187-.534a1.735 1.735 0 0 1-.067-.474c0-.872.415-1.312 1.261-1.312.88 0 1.313.431 1.313 1.287 0 .22-.034.372-.094.474-.237.626-.355.965-.372 1.016-.254.5-.406.821-.474.965-.119.27-.195.508-.22.72-.051.101-.085.186-.102.262s-.034.136-.05.186m-2.778 8.56c-2.066.034-3.954.136-5.673.322-1.71.178-3.03.44-3.979.77a18.973 18.973 0 0 0-1.719-1.854 33.007 33.007 0 0 1-1.727-1.744c-.83-.847-1.236-1.77-1.236-2.777 0-1.245.203-2.15.618-2.726.44-.67 1.135-1.16 2.058-1.482a8.486 8.486 0 0 1 2.802-.483c1.194 0 2.328.263 3.42.796 1.076.56 1.787 1.008 2.134 1.338 1.126 1.143 2.007 2.38 2.633 3.717.212.5.373 1.194.483 2.074.11.89.17 1.567.186 2.05zm2.777-4.318c.119-.466.212-.787.296-.965.17-.643.356-1.194.576-1.643.093-.279.237-.6.432-.973.186-.373.39-.805.61-1.279.127-.28.27-.626.415-1.033.152-.406.304-.804.448-1.202.136-.33.203-.686.203-1.067 0-.813-.296-1.498-.872-2.066-.575-.575-1.278-.863-2.108-.863-1.964 0-2.955.99-2.955 2.955 0 .38.068.736.203 1.066.365 1.076.644 1.82.839 2.236.22.474.415.906.6 1.278.179.373.34.694.466.974.22.55.398 1.092.55 1.642.035.093.128.415.297.94m-.889 6.223c0-.66-.017-1.575-.05-2.735-.034-1.168-.161-2.142-.373-2.92-.677-2.21-1.702-3.997-3.082-5.351-.711-.695-1.795-1.347-3.268-1.94-1.685-.66-3.285-.99-4.792-.99-2.608 0-4.547.931-5.8 2.803-.711.99-1.067 2.235-1.067 3.716 0 1.626.398 2.955 1.186 3.997.415.592 1.21 1.329 2.387 2.21 1.169.872 2.168 1.684 2.972 2.43 1.44-.314 3.065-.585 4.877-.822 1.812-.229 4.149-.364 7.01-.398m13.784 11.735-.737-2.93c-3.225-.736-7.281-1.109-12.158-1.109-4.826 0-8.864.373-12.107 1.11l-.787 2.954c3.14-.956 7.442-1.439 12.92-1.439 2.624 0 5.071.136 7.315.398 2.252.262 4.106.601 5.554 1.016m-.643-7.417c-3.04-.838-7.096-1.261-12.15-1.261-5.097 0-9.195.431-12.302 1.287l.372 2.506c3.125-.813 7.095-1.22 11.93-1.22 4.809 0 8.729.398 11.752 1.194zm-11.363-4.292c2.845.05 5.182.194 7.002.423 1.812.229 3.455.508 4.91.821.907-.897 1.914-1.744 3.023-2.557s1.888-1.507 2.337-2.083c.788-1.075 1.186-2.413 1.186-4.021 0-1.465-.356-2.701-1.067-3.692-1.27-1.87-3.218-2.802-5.825-2.802-1.524 0-3.108.33-4.767.99-1.507.593-2.59 1.237-3.277 1.93-1.405 1.364-2.43 3.15-3.073 5.36-.246.762-.381 1.727-.407 2.904s-.042 2.083-.042 2.727m1.812-1.93c0-.483.06-1.16.16-2.05.111-.88.28-1.575.509-2.074.618-1.338 1.49-2.574 2.633-3.717.33-.33 1.042-.779 2.134-1.338a7.655 7.655 0 0 1 3.446-.796c.93 0 1.845.161 2.768.483.915.322 1.609.813 2.066 1.482.415.559.627 1.464.627 2.726 0 .99-.407 1.913-1.22 2.777a40.35 40.35 0 0 1-1.71 1.651c-.61.55-1.202 1.202-1.76 1.947-.958-.33-2.295-.592-4.006-.77-1.71-.186-3.59-.288-5.647-.322z"/>`,
|
||||
bq: `<linearGradient id="a" x1="21.253" x2="77.764" y1="37.224" y2="37.36" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M24.95 10.752c-.94 0-1.745-.33-2.397-.99s-.974-1.465-.974-2.405c0-.931.322-1.727.974-2.387s1.456-.99 2.396-.99c.923 0 1.727.33 2.396.99a3.23 3.23 0 0 1 1 2.387c0 .94-.33 1.744-1 2.405-.669.66-1.473.99-2.396.99zm15.281 33.19c-.812.71-2.633 1.304-5.46 1.786-2.828.474-6.088.72-9.771.72-3.75 0-7.053-.254-9.898-.745-2.844-.5-4.64-1.118-5.384-1.863l1.566-5.952-.694-3.895L8.405 30.2 6.297 14.774l1.21-.474 6.8 11.455.152-13.64 1.685-.296 5.182 13.716 2.776-14.757h1.72l2.776 14.706L33.73 11.82l1.71.296.153 13.64 6.824-11.48 1.16.541-2.058 15.359-2.21 3.793-.694 3.945zM14.535 11.989c-.948 0-1.752-.322-2.413-.974-.66-.652-.99-1.456-.99-2.396 0-.923.33-1.719.99-2.38s1.465-.99 2.413-.99c.923 0 1.719.33 2.38.99s.99 1.457.99 2.38c0 .94-.33 1.744-.99 2.396a3.266 3.266 0 0 1-2.38.974m20.828 0c-.94 0-1.736-.322-2.387-.974-.652-.652-.982-1.456-.982-2.396 0-.923.33-1.719.982-2.38s1.447-.99 2.387-.99c.948 0 1.753.33 2.413.99s.99 1.457.99 2.38c0 .94-.33 1.744-.99 2.396s-1.465.974-2.413.974M5.4 14.723c-.94 0-1.736-.33-2.388-.982s-.982-1.448-.982-2.396c0-.923.33-1.719.982-2.388C3.664 8.28 4.46 7.95 5.4 7.95c.948 0 1.744.33 2.413 1.007.66.67.99 1.465.99 2.388 0 .948-.33 1.744-.99 2.396a3.323 3.323 0 0 1-2.413.982m39.141 0c-.94 0-1.744-.33-2.404-.982s-.991-1.448-.991-2.396q0-1.3845.99-2.388c.66-.677 1.465-1.007 2.405-1.007.931 0 1.727.33 2.388 1.007.66.67.99 1.465.99 2.388 0 .948-.33 1.744-.99 2.396a3.28 3.28 0 0 1-2.388.982"/><path fill="url(#a)" d="M37.2 35.73c-3.04-.84-7.095-1.262-12.15-1.262-5.096 0-9.194.431-12.301 1.286l.372 2.507c3.124-.813 7.095-1.22 11.93-1.22 4.809 0 8.729.398 11.752 1.194zm1.736-4.437c-1.372-.5-3.302-.906-5.791-1.228-2.49-.322-5.233-.483-8.247-.483-2.946 0-5.638.153-8.085.458-2.447.304-4.378.702-5.783 1.202l1.245 2.252c1.388-.406 3.191-.703 5.41-.89 2.21-.177 4.631-.27 7.264-.27s5.063.093 7.29.27q3.3525.2805 5.436.915zm-1.092 11.853-.737-2.93c-3.226-.736-7.281-1.109-12.158-1.109-4.826 0-8.864.373-12.107 1.11l-.788 2.954c3.142-.956 7.443-1.44 12.92-1.44 2.625 0 5.072.136 7.316.399 2.252.262 4.106.601 5.554 1.016"/>`,
|
||||
br: `<linearGradient id="a" x1="21.192" x2="77.736" y1="37.552" y2="37.429" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M28.408 9.22h4.216V5.825h6.799v9.296l-5.503 4.242v11.862l4.216 4.216v5.08h3.793v5.927H8.071V40.52h3.793v-5.08l4.242-4.216V19.363l-5.504-4.242V5.825h6.774V9.22h4.242V5.825h6.79z"/><path fill="url(#a)" d="M25.013 35.043h-10.27L13.6 36.11v1.44h22.826v-1.44l-1.143-1.067zM13.6 40.123v2.532h22.826v-2.532zM25.013 13.04h-12.7v1.142l1.812 1.364h21.801l1.761-1.364V13.04zm0 4.19h-8.679l1.482 1.169v1.414h14.393v-1.414l1.482-1.168zm0 13.547h-7.197v1.143l-1.482 1.44h17.357l-1.482-1.44v-1.143z"/>`,
|
||||
bb: `<linearGradient id="a" x1="21.094" x2="77.669" y1="37.101" y2="37.469" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M25 42.162c-.229.94-.516 1.592-.847 1.956-.33.364-.762.745-1.312 1.143-.593.415-1.295.762-2.108 1.05s-1.71.364-2.701.211l-6.968-.965a2.858 2.858 0 0 0-.762 0c-.22.034-.432.051-.635.051-.347 0-.787.076-1.32.237-.543.153-.958.381-1.254.677l-2.405-3.945c.297-.33.56-.559.788-.694.237-.127.508-.271.821-.415a9.179 9.179 0 0 1 3.073-.821c.466-.034.923-.043 1.364-.026a9.8 9.8 0 0 0 1.397-.05c.889.152 1.786.287 2.684.406.905.127 1.811.254 2.717.39.991 0 1.66-.102 2.007-.297.186-.102.474-.288.872-.55.398-.263.796-.652 1.194-1.169-.88-.093-1.77-.262-2.684-.508a24.094 24.094 0 0 1-2.405-.753l2.583-6.401c-1.296-.745-2.193-1.338-2.71-1.795a5.3 5.3 0 0 1-1.21-1.575c-.432-.762-.712-1.498-.83-2.21a9.341 9.341 0 0 1-.16-1.913c.016-.99.245-2.083.702-3.285.457-1.194 1.312-2.27 2.565-3.209a79.091 79.091 0 0 0 3.057-2.455 27.746 27.746 0 0 0 2.946-2.955c-1.22-.627-1.829-1.626-1.829-2.997 0-.932.322-1.72.974-2.388.652-.66 1.456-.99 2.396-.99.923 0 1.719.33 2.38.99.66.669.99 1.456.99 2.388q0 2.031-1.83 2.997a26.796 26.796 0 0 0 2.914 2.955 56.74 56.74 0 0 0 3.09 2.455c1.236.94 2.083 2.015 2.523 3.209.449 1.202.694 2.294.72 3.285 0 .567-.051 1.202-.17 1.913s-.38 1.448-.796 2.21a6.084 6.084 0 0 1-1.253 1.575c-.5.457-1.388 1.05-2.667 1.795l2.583 6.4c-.729.263-1.55.517-2.456.754-.914.246-1.786.415-2.633.508.381.517.77.906 1.168 1.169.398.262.695.448.898.55.347.195 1.016.296 2.007.296a263.35 263.35 0 0 1 2.692-.39 81.13 81.13 0 0 0 2.718-.406c.44.051.889.068 1.346.051a13.12 13.12 0 0 1 1.405.026 9.627 9.627 0 0 1 3.074.82c.296.145.567.289.804.416.246.135.508.364.804.694l-2.43 3.945c-.296-.296-.71-.524-1.253-.677-.533-.16-.965-.237-1.295-.237-.22 0-.44-.017-.66-.05a2.794 2.794 0 0 0-.754 0l-6.95.964c-.992.153-1.914.085-2.761-.194-.855-.28-1.558-.652-2.1-1.118-.542-.449-.982-.83-1.304-1.151-.321-.322-.592-.957-.804-1.897"/><path fill="url(#a)" d="M24.086 23.705v2.108c0 .61.304.914.914.914s.914-.304.914-.914v-2.134h2.236c.575 0 .872-.296.872-.897 0-.593-.297-.889-.872-.889h-2.236v-2.235c0-.61-.304-.915-.914-.915s-.914.305-.914.915v2.235H21.9c-.584 0-.872.296-.872.889 0 .601.288.897.872.897zm7.51 13.741-1.042-2.531c-1.685-.364-3.539-.542-5.554-.542-1.998 0-3.835.178-5.503.542l-1.042 2.506c2.05-.517 4.234-.77 6.545-.77 2.286 0 4.479.262 6.596.795m-2.083-5.114-.72-1.735v-.67a27.03 27.03 0 0 0-3.793-.27 27.35 27.35 0 0 0-3.768.27l-.025.67-.669 1.735A25.85 25.85 0 0 1 25 31.96c1.592 0 3.09.127 4.513.372m-.864 9.381c-.66-.5-1.33-1.287-1.99-2.362h-.787c0 .813.186 1.6.567 2.362zm-5.114 0c.381-.812.576-1.6.576-2.362h-.796c-.643 1.059-1.312 1.846-2.015 2.362z"/>`,
|
||||
bn: `<linearGradient id="a" x1="21.253" x2="77.641" y1="37.592" y2="37.469" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><path fill="#1f1a17" d="M26.178 9.395c2.6.17 5.004.838 7.222 2.015 2.21 1.169 4.098 2.676 5.656 4.513 1.092 1.287 2.117 2.845 3.082 4.665a28.684 28.684 0 0 1 2.32 5.774 36.511 36.511 0 0 1 1.253 7.46c.177 2.599.262 5.012.262 7.23v5.402H15.468c-.153 0-.22-.407-.212-1.21.009-.814.06-1.466.16-1.965.06-.398.221-.957.467-1.685.254-.728.66-1.609 1.244-2.65.263-.534.89-1.304 1.88-2.32.999-1.016 2.133-2.201 3.429-3.539.745-.762 1.32-1.719 1.744-2.879.423-1.151.601-2.201.533-3.15a8.37 8.37 0 0 1-2.006 1.22c-3.505 1.253-6.045 3.073-7.612 5.452-.118.153-.49.822-1.117 2.015-.33.627-.618 1.059-.847 1.287-.313.314-.77.491-1.363.525-.923.043-1.643-.398-2.16-1.346-.693.203-1.312.288-1.862.254-.923-.347-1.592-.72-2.006-1.117-.847-.847-1.389-1.685-1.651-2.532a9.43 9.43 0 0 1-.381-2.726c0-1.389.855-3.226 2.582-5.512 2.015-2.625 3.09-4.631 3.217-6.003 0-.593.06-1.261.178-2.007a4.198 4.198 0 0 1 .618-1.49c.22-.33.364-.558.432-.677.076-.127.212-.313.415-.559.144-.203.27-.355.372-.457.093-.11.22-.254.373-.44.178-.212.406-.457.694-.745a18.06 18.06 0 0 1-1.067-7.46c3.285 1.169 6.054 3.015 8.28 5.53.551-1.872 1.626-3.387 3.226-4.539 1.321.923 2.371 2.15 3.15 3.666"/><path fill="url(#a)" d="m15.688 17.786.542-.28c.5-.194.652-.559.474-1.092-.195-.491-.576-.66-1.143-.491-1.947.711-3.294 2.015-4.039 3.92-.118.542.076.914.593 1.118.516.16.864-.017 1.041-.55.136-.28.229-.466.297-.543.186.144.423.246.72.297 1.007.16 1.6-.28 1.76-1.338a1.498 1.498 0 0 0-.245-1.041M11.573 34.55c.06-.153.17-.373.322-.67.28-.693.415-1.108.415-1.244-.026-.457-.271-.694-.72-.694-.33 0-.711.474-1.16 1.414a.97.97 0 0 1-.296.347c-.449.466-.381.855.194 1.168.534.314.94.212 1.245-.321m14.63-9.204c1.16-1.524 1.728-3.217 1.71-5.08-.067-.55-.38-.82-.94-.82-.761 0-1.057.279-.897.837.051.915-.033 1.668-.27 2.261-.382.94-.805 1.643-1.262 2.108-.254.5-.102.864.449 1.092.525.246.931.119 1.21-.398M19.726 13.24a6.798 6.798 0 0 1 .051-1.93c-.99.194-1.922.66-2.802 1.388-.525.28-.652.67-.373 1.169.28.508.67.592 1.169.245.347-.186.669-.355.956-.508.288-.16.618-.28 1-.364zm23.25 31.454c-.017 0 0-.449.042-1.346.131-3.108.096-6.221.076-9.33a26.837 26.837 0 0 0-.889-6.613c-.84-3.31-2.124-6.485-4.072-9.297-2.634-3.845-6.814-6.033-11.286-6.976.126.766.033 1.54.076 2.311a25.82 25.82 0 0 1 4.538 2.032c4.241 2.555 6.414 7.276 7.197 11.93 1.272 6.154.453 11.557.813 17.289zM9.439 30.139c.475-.34.525-.729.144-1.194-.398-.381-.83-.415-1.312-.102-1.007.66-1.55 1.533-1.617 2.608.017.542.347.804.974.77.592-.05.88-.355.863-.922.136-.525.449-.915.948-1.16"/>`,
|
||||
bp: `<path fill="#1f1a17" d="M25 46.448H11.606a13.139 13.139 0 0 1-.99-5.043c0-2.975.863-5.644 2.598-8.018 1.736-2.365 3.971-4.054 6.697-5.067a6.824 6.824 0 0 1-2.861-2.398c-.737-1.071-1.1-2.283-1.1-3.634 0-1.69.575-3.156 1.735-4.392 1.151-1.244 2.574-1.961 4.267-2.15-1.346-.981-2.015-2.283-2.015-3.89 0-1.351.491-2.513 1.482-3.477.982-.964 2.176-1.442 3.581-1.442 1.389 0 2.582.478 3.573 1.442s1.49 2.126 1.49 3.477c0 1.607-.669 2.909-2.015 3.89 1.693.189 3.116.906 4.267 2.15 1.16 1.236 1.736 2.703 1.736 4.392 0 1.351-.373 2.563-1.126 3.634a7.036 7.036 0 0 1-2.862 2.398c2.726 1.013 4.962 2.702 6.697 5.067 1.736 2.374 2.6 5.043 2.6 8.018q0 2.6085-.966 5.043z"/>`,
|
||||
},
|
||||
},
|
||||
staunty: {
|
||||
license: "CC BY-NC-SA 4.0 (sadsnake1)",
|
||||
vbX: 0,
|
||||
vbY: 0,
|
||||
vbW: 50,
|
||||
vbH: 50,
|
||||
pieces: {
|
||||
wk: `<path fill="#f0f0f0" stroke="#3c3c3c" stroke-linecap="round" stroke-width="1.2" d="M27.67 15.22v-3.54h4.44V7.25h-4.93V3.36H22.8v3.9h-4.93v4.42h4.44v3.55"/><rect width="9.4" height="2.79" x="20.3" y="14.21" fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" ry="1.39"/><path d="M26.42 14.21c.72 0 1.3.63 1.3 1.4s-.58 1.4-1.3 1.4h1.97c.72 0 1.3-.63 1.3-1.4s-.58-1.4-1.3-1.4z" opacity=".15"/><path fill="#fff" d="M21.63 14.84c-.4 0-.72.35-.72.78 0 .42.32.77.72.77h.88c-.4 0-.73-.35-.73-.78 0-.42.32-.77.72-.77z"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linecap="round" stroke-width="1.2" d="M33.63 36.99s7.78-13.32 6.62-15.92c-1.17-2.6-8.48-4.5-15.25-4.5s-14.08 1.9-15.25 4.5c-1.16 2.6 6.61 15.92 6.61 15.92z"/><path d="M25 16.58c15.93 2.62 12.57 9.35 6.64 22.54l2.02-1.73s7.75-13.72 6.59-16.32c-1.55-2.83-7.5-4.16-15.25-4.5z" opacity=".15"/><path fill="#fff" d="M23.77 17.3c-3.9-.19-14.63 1.8-13.5 5.01.8 3.73 2.75 7.25 4.5 10.5-5.69-10.33-5.94-13.77 9-15.52zM23.39 4l-.02 3.3h.55l.02-3.3zm-4.93 3.87v3.2h.77v-3.2zm4.41 3.21.03 2.49h.52l-.03-2.49z"/><path d="M26.19 3.36v3.9h.99v-3.9zm4.44 3.9v4.94h1.48V7.25zm-4.44 4.42v2.5h1.48v-2.5z" opacity=".15"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25 36.46s-9.13.04-11.7 1.62c-1.72 1.06-2.13 3.65-1.9 6.32h27.2c.23-2.67-.18-5.26-1.9-6.32C34.12 36.5 25 36.46 25 36.46z"/><path fill="#fff" d="M25 37.15S16.29 37 13.38 38.8c-.37.23-.7.84-.96 1.4.26-.34.5-.62.89-.86C15.87 37.78 25 37.73 25 37.73s9.13.05 11.7 1.62c.38.24.58.54.85.87a3 3 0 0 0-1.15-1.6C33.65 37.15 25 37.16 25 37.16z"/>`,
|
||||
wq: `<path fill="#f0f0f0" stroke="#3c3c3c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M24.959 5.094a2.958 3.316 90 0 0-3.316 2.958 2.958 3.316 90 0 0 3.316 2.959 2.958 3.316 90 0 0 3.316-2.959 2.958 3.316 90 0 0-3.316-2.958"/><path fill="#fff" d="M24.836 5.732c-.376-.21-3.724.806-2.185 3.576-.235-1.545.438-3.203 2.185-3.576"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M24.959 11.011c-6.507 0-9.595 5.884-9.595 10.358h19.263c0-4.474-3.16-10.358-9.668-10.358"/><path fill="#fff" d="M18.161 14.977c1.042-1.478 2.92-3.22 6.84-3.38-.31.277-4.788 1.138-6.84 3.38"/><path d="M24.836 5.007s.046.238 0 0c2.48 1.129 2.05 3.847.817 5.547 7.354 3.803 2.213 8.669 2.212 8.668h2.701c1.762 1.287 7.209-2.741-3.835-8.67 3.528-3.115.097-5.606-1.895-5.546z" opacity=".15"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M25 15.225c-1.971 0-2.348 2.65-4.137 2.86-1.82.213-3.381-2.312-5.25-1.737-1.495.46-.778 2.6-1.805 3.175-1.402.785-3.185-1.832-5.29-.298 6.838 8.829 8.085 12.377 7.983 18.819h16.998c-.103-6.443 1.144-9.99 7.983-18.82-2.106-1.533-3.889 1.084-5.29.3-1.027-.576-.311-2.716-1.806-3.176-1.868-.575-3.429 1.95-5.25 1.736-1.789-.21-2.166-2.86-4.137-2.86z"/><path fill="#fff" d="M9.895 19.34c-.136-.01-.331.056-.458.085 3.081 4.1 6.575 9.537 7.099 12.417-1.407-4.933-3.267-9.562-6.14-12.472z"/><path d="M39.974 18.735c-9.485 10.003-9.924 17.985-16.941 19.31h10.476c-.103-6.443 1.145-9.99 7.983-18.819 0 0-.688-.756-1.518-.491" opacity=".15"/><path fill="#fff" d="M14.912 18.945c.203-.088 1.184-1.808 1.98-1.95-1.42-.346-1.618-.046-1.98 1.95m7.599-1.069c.953-.847 1.633-2.655 3.238-1.845-.798-.23-2.215 1.04-3.238 1.845m8.609.257c.21.07 2.176-1.642 2.862-1.218 0 0-1.43 1.12-2.862 1.218"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25 36.457s-9.13.048-11.691 1.62c-1.727 1.06-2.135 3.65-1.9 6.323h27.182c.235-2.672-.172-5.264-1.9-6.324-2.56-1.571-11.69-1.62-11.69-1.62z"/><path fill="#fff" d="M25 37.147s-8.712-.137-11.624 1.666c-.37.229-.7.84-.954 1.39.261-.331.502-.613.887-.849C15.869 37.783 25 37.734 25 37.734s9.132.049 11.692 1.62c.391.24.593.532.856.87.026-.076-.409-1.158-1.144-1.596C33.648 37.136 25 37.147 25 37.147"/>`,
|
||||
wr: `<path fill="#f0f0f0" stroke="#3c3c3c" stroke-width="1.2" d="M17.93 20.41c4.9-.74 9.58-.57 14.14 0M14.18 9.66c-1.06 8.77 1.1 10.68 3.75 10.75l-3.31 18.16h20.76l-3.31-18.16c2.64-.07 4.8-1.98 3.75-10.75l-3.61-.53-1.07 3.65-3.15-.1-.52-3.76h-4.94L22 12.68l-3.15.1-1.07-3.65z"/><path d="M17.93 20.41c6.83 0 13.12.41 14.95 16.58l2.32.38-3.13-16.43c-.03-.3-6.09-1.82-14.14-.53" opacity=".15"/><path fill="#fff" d="m14.78 10.22 2.27-.29c-1.91.32-2.3 5.3-2.3 5.3-.25-.18-.2-4.9.03-5.01m10.5-.67c-1.65 0-2.52 2.75-2.52 2.75l.33-2.73zm7.4.27.92.11c-.78.5-1.59 2-1.59 2zm-14.2 11.14 2.61-.29c-2.62.3-4.89 13.11-4.89 13.11z"/><path d="M34.01 9.4c.36 6.36-1.95 10.6-8.04 10.53l4.78.57c7.52.3 5.1-10.8 5.07-10.84z" opacity=".15"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25 36.46s-9.13.04-11.7 1.62c-1.72 1.06-2.13 3.65-1.9 6.32h27.2c.23-2.67-.18-5.26-1.9-6.32C34.12 36.5 25 36.46 25 36.46z"/><path fill="#fff" d="M25 37.15S16.29 37 13.38 38.8c-.37.23-.7.84-.96 1.4.26-.34.5-.62.89-.86C15.87 37.78 25 37.73 25 37.73s9.13.05 11.7 1.62c.38.24.58.53.85.87a3 3 0 0 0-1.15-1.6C33.65 37.15 25 37.16 25 37.16z"/>`,
|
||||
wb: `<path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25 5.77c-2.1 0-3.81.88-3.81 1.96l1.51 2.65C6.65 24.47 17 37.52 17 37.52h16s7.05-8.68.77-19.51l-3 4.82c-.66 1.09-1.96 1.5-2.9.91-.93-.57-1.14-1.91-.47-3l3.89-6.27a35.38 35.38 0 0 0-4-4.09l1.52-2.65c0-1.08-1.7-1.96-3.8-1.96z"/><path d="M25 5.77c-.82 0-1.57.13-2.2.36 4.35.84 4.99 1.12 2.57 4.35l3.24 3.56c-3.65 8.24-1.6 8-1.6 8s.7-2.65 4.11-7.77a35.7 35.7 0 0 0-3.82-3.89l1.51-2.65c0-1.08-1.7-1.96-3.81-1.96M33.77 18l-1.01 1.52c3.73 8.41-4.14 18-4.14 18H33c.16.03 6.96-8.85.77-19.52" opacity=".15"/><path fill="#fff" d="M15.14 31.72c-.22-.03-3.42-9.78 5.76-18.75-2.3 1.9-7.14 13.16-5.75 18.75zM23.3 10.2l-1.47-2.6s.24-.72 1.78-1.05c-1.73 1.35-1 1.67-.3 3.65z"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25 36.46s-9.13.04-11.7 1.62c-1.72 1.06-2.13 3.65-1.9 6.32h27.2c.23-2.67-.18-5.26-1.9-6.32C34.12 36.5 25 36.46 25 36.46z"/><path fill="#fff" d="M25 37.15S16.29 37 13.38 38.8c-.37.23-.7.84-.96 1.4.26-.34.5-.62.89-.86 2.56-1.56 11.69-1.6 11.69-1.6s9.13.04 11.7 1.61c.38.24.58.54.85.87a3 3 0 0 0-1.15-1.6C33.65 37.15 25 37.16 25 37.16z"/>`,
|
||||
wn: `<path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25.192 23.015c-.1654 6.9672-11.758 5.2189-11.516 18.104l22.86.1184c-2.094-6.442 9.69-25.16-11.931-32.258v-.0001s-2.4381-2.601-5.9655-2.8237l.2227 3.5347-4.5583 4.5816c-2.6294 3.1455-8.7347 8.3784-7.7513 9.6111 3.1158 5.3041 6.3306 4.4316 6.3306 4.4316 4.2418-4.5433 5.8193-2.0894 12.309-5.2997z"/><path d="M19.32 14.694c-.7757.8609-.6902 1.1156-.8137 2.1503.8055.1232 1.5069.2398 2.2486.0656 2.3809-1.262.075-3.4026-1.4347-2.2162z" opacity=".35" paint-order="fill markers stroke"/><path d="M9.1916 22.166c-.8496.4078-.9984.9608-1.0565 1.4754.7288.4181 1.8765-.1255 2.0412-1.4316l-.9846-.044z" opacity=".3"/><path fill="#fff" d="M8.1905 25.15s.6525 1.1374-1.1019-1.641c.6594-1.9774 8.263-9.0796 12.438-13.534l-.1836-3.0857s1.0689 1.6901 1.2475 3.468c-4.3898 4.39-12.22 10.833-12.824 13.213.023.6738.24 1.0278.4231 1.5797z"/><path d="M13.26 28.257c2.0291-3.3367 8.3914-3.2239 11.932-5.2424.3228.1024.1304 1.3697.2398 1.23.8476-1.0903 2.9259-3.279.8684-6.8743.5214 5.9575-13.718 5.5912-15.89 10.305-.2005.4355 2.1818.7932 2.85.5818z" opacity=".15"/><path fill="#fff" d="M25.8 23.781c-1.0131 5.8132-9.5449 6.1169-10.988 12.641 2.8332-6.4058 10.762-5.7136 10.988-12.641"/><path d="M18.64 6.1556s3.051.738 4.9045 3.9825c20.499 7.1536 7.6413 27.937 5.7883 31.073l7.2034.026c-1.9871-3.2431 9.5482-25.597-11.931-32.258-1.7757-1.0691-2.7677-2.6092-5.9655-2.8238z" opacity=".15"/><path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M25 36.457s-9.1309.048-11.691 1.6192c-1.7273 1.0602-2.1348 3.6514-1.8998 6.3237h27.182c.235-2.6723-.1725-5.2636-1.8999-6.3237-2.5597-1.5711-11.691-1.6192-11.691-1.6192z"/><path fill="#fff" d="M25 37.147s-8.7121-.1373-11.624 1.6658c-.3698.2291-.6992.8394-.9536 1.3902.2608-.3313.5022-.613.8867-.849 2.5598-1.5711 11.691-1.6191 11.691-1.6191s9.1318.048 11.692 1.6191c.391.24.5924.5316.8556.8701.026-.076-.4084-1.1578-1.1438-1.5962-2.7554-1.492-11.403-1.4808-11.403-1.4808z"/>`,
|
||||
wp: `<path fill="#f0f0f0" stroke="#3c3c3c" stroke-linejoin="round" stroke-width="1.2" d="M21.503 27.594h6.994M19 17.508a6.35 6.35 0 0 0 1.966 4.587l-3.65 2.1.43 3.399h4.306c-.794 3.559-2.755 7.33-5.062 8.617s-5.3 3.097-4.843 8.189h25.706c.457-5.092-2.535-6.902-4.842-8.189-2.307-1.286-4.268-5.058-5.062-8.617h4.306l.43-3.4-3.65-2.099a6.352 6.352 0 0 0 1.966-4.587c0-3.367-2.628-5.912-6-5.912-3.373 0-6.002 2.545-6.001 5.912z"/><path d="M24.962 11.537c1.17-.459 9.527 5.906.647 10.773l4.512 2.1-.562 3.125h2.659l.428-3.399-3.65-2.1c1.253-1.2 1.962-2.58 1.964-4.312-.468-5.416-5.998-6.186-5.998-6.186zm-2.949 15.998c4.503 7.934 9.47 9.994 13.074 9.965l-2.115-1.347c-2.075-1.49-4.732-4.858-5.062-8.618z" opacity=".15"/><path fill="#fff" d="m21.983 22.213-1.647 2.347-2.356-.014 4.013-2.324zm2.324-9.946c-2.542.138-5.73 3.173-4.385 6.918l.199.643c-.33-3.489 2.127-7.116 4.186-7.561m-6.444 25.358c-3.984 2.305-5.117 6.14-5.117 6.14-.01 0-.548-4.175 3.956-6.654s4.822-6.15 5.86-8.893c-.636 3.704-.715 7.102-4.699 9.407"/>`,
|
||||
bk: `<path fill="#5f5955" stroke="#1e1e1e" stroke-linecap="round" stroke-width="1.2" d="M27.67 15.22v-3.54h4.44V7.25h-4.93V3.36H22.8v3.9h-4.93v4.42h4.44v3.55"/><rect width="9.4" height="2.79" x="20.3" y="14.21" fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" ry="1.39"/><path d="M26.42 14.21c.72 0 1.3.63 1.3 1.4s-.58 1.4-1.3 1.4h1.97c.72 0 1.3-.63 1.3-1.4s-.58-1.4-1.3-1.4z" opacity=".18"/><path fill="#fff" d="M21.63 14.84c-.4 0-.72.35-.72.78 0 .42.32.77.72.77h.88c-.4 0-.73-.35-.73-.78 0-.42.32-.77.72-.77z" opacity=".25"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linecap="round" stroke-width="1.2" d="M33.63 36.99s7.78-13.32 6.62-15.92c-1.17-2.6-8.48-4.5-15.25-4.5s-14.08 1.9-15.25 4.5c-1.16 2.6 6.61 15.92 6.61 15.92z"/><path d="M25 16.58c15.93 2.62 12.57 9.35 6.64 22.54l2.02-1.73s7.75-13.72 6.59-16.32c-1.55-2.83-7.5-4.16-15.25-4.5z" opacity=".18"/><path fill="#fff" d="M23.77 17.3c-3.9-.19-14.63 1.8-13.5 5.01.8 3.73 2.75 7.25 4.5 10.5-5.69-10.33-5.94-13.77 9-15.52zM23.39 4l-.02 3.3h.55l.02-3.3zm-4.93 3.87v3.2h.77v-3.2zm4.41 3.21.03 2.49h.52l-.03-2.49z" opacity=".25"/><path d="M26.19 3.36v3.9h.99v-3.9zm4.44 3.9v4.94h1.48V7.25zm-4.44 4.42v2.5h1.48v-2.5z" opacity=".18"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25 36.46s-9.13.04-11.7 1.62c-1.72 1.06-2.13 3.65-1.9 6.32h27.2c.23-2.67-.18-5.26-1.9-6.32C34.12 36.5 25 36.46 25 36.46z"/><path fill="#fff" d="M25 37.15S16.29 37 13.38 38.8c-.37.23-.7.84-.96 1.4.26-.34.5-.62.89-.86C15.87 37.78 25 37.73 25 37.73s9.13.05 11.7 1.62c.38.24.58.54.85.87a3 3 0 0 0-1.15-1.6C33.65 37.15 25 37.16 25 37.16z" opacity=".25"/>`,
|
||||
bq: `<path fill="#5f5955" stroke="#1e1e1e" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M24.959 5.094a2.958 3.316 90 0 0-3.316 2.958 2.958 3.316 90 0 0 3.316 2.959 2.958 3.316 90 0 0 3.316-2.959 2.958 3.316 90 0 0-3.316-2.958"/><path fill="#fff" d="M24.836 5.732c-.376-.21-3.724.806-2.185 3.576-.235-1.545.438-3.203 2.185-3.576" opacity=".25"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M24.959 11.011c-6.507 0-9.595 5.884-9.595 10.358h19.263c0-4.474-3.16-10.358-9.668-10.358"/><path fill="#fff" d="M18.161 14.977c1.042-1.478 2.92-3.22 6.84-3.38-.31.277-4.788 1.138-6.84 3.38" opacity=".25"/><path d="M24.836 5.007s.046.238 0 0c2.48 1.129 2.05 3.847.817 5.547 7.354 3.803 2.213 8.669 2.212 8.668h2.701c1.762 1.287 7.209-2.741-3.835-8.67 3.528-3.115.097-5.606-1.895-5.546z" opacity=".18"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M25 15.225c-1.971 0-2.348 2.65-4.137 2.86-1.82.213-3.381-2.312-5.25-1.737-1.495.46-.778 2.6-1.805 3.175-1.402.785-3.185-1.832-5.29-.298 6.838 8.829 8.085 12.377 7.983 18.819h16.998c-.103-6.443 1.144-9.99 7.983-18.82-2.106-1.533-3.889 1.084-5.29.3-1.027-.576-.311-2.716-1.806-3.176-1.868-.575-3.429 1.95-5.25 1.736-1.789-.21-2.166-2.86-4.137-2.86z"/><path fill="#fff" d="M9.895 19.34c-.136-.01-.331.056-.458.085 3.081 4.1 6.575 9.537 7.099 12.417-1.407-4.933-3.267-9.562-6.14-12.472z" opacity=".25"/><path d="M39.974 18.735c-9.485 10.003-9.924 17.985-16.941 19.31h10.476c-.103-6.443 1.145-9.99 7.983-18.819 0 0-.688-.756-1.518-.491" opacity=".18"/><path fill="#fff" d="M14.912 18.945c.203-.088 1.184-1.808 1.98-1.95-1.42-.346-1.618-.046-1.98 1.95m7.599-1.069c.953-.847 1.633-2.655 3.238-1.845-.798-.23-2.215 1.04-3.238 1.845m8.609.257c.21.07 2.176-1.642 2.862-1.218 0 0-1.43 1.12-2.862 1.218" opacity=".25"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25 36.457s-9.13.048-11.691 1.62c-1.727 1.06-2.135 3.65-1.9 6.323h27.182c.235-2.672-.172-5.264-1.9-6.324-2.56-1.571-11.69-1.62-11.69-1.62z"/><path fill="#fff" d="M25 37.147s-8.712-.137-11.624 1.666c-.37.229-.7.84-.954 1.39.261-.331.502-.613.887-.849C15.869 37.783 25 37.734 25 37.734s9.132.049 11.692 1.62c.391.24.593.532.856.87.026-.076-.409-1.158-1.144-1.596C33.648 37.136 25 37.147 25 37.147" opacity=".25"/>`,
|
||||
br: `<path fill="#5f5955" stroke="#1e1e1e" stroke-width="1.2" d="M17.93 20.41c4.9-.74 9.58-.57 14.14 0M14.18 9.66c-1.06 8.77 1.1 10.68 3.75 10.75l-3.31 18.16h20.76l-3.31-18.16c2.64-.07 4.8-1.98 3.75-10.75l-3.61-.53-1.07 3.65-3.15-.1-.52-3.76h-4.94L22 12.68l-3.15.1-1.07-3.65z"/><path d="M17.93 20.41c6.83 0 13.12.41 14.95 16.58l2.32.38-3.13-16.43c-.03-.3-6.09-1.82-14.14-.53" opacity=".18"/><path fill="#fff" d="m14.78 10.22 2.27-.29c-1.91.32-2.3 5.3-2.3 5.3-.25-.18-.2-4.9.03-5.01m10.5-.67c-1.65 0-2.52 2.75-2.52 2.75l.33-2.73zm7.4.27.92.11c-.78.5-1.59 2-1.59 2zm-14.2 11.14 2.61-.29c-2.62.3-4.9 13.05-4.9 13.11z" opacity=".25"/><path d="M34.01 9.4c.36 6.36-1.95 10.6-8.04 10.53l4.78.57c7.52.3 5.1-10.8 5.07-10.84z" opacity=".18"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25 36.46s-9.13.04-11.7 1.62c-1.72 1.06-2.13 3.65-1.9 6.32h27.2c.23-2.67-.18-5.26-1.9-6.32C34.12 36.5 25 36.46 25 36.46z"/><path fill="#fff" d="M25 37.15S16.29 37 13.38 38.8c-.37.23-.7.84-.96 1.4.26-.34.5-.62.89-.86C15.87 37.78 25 37.73 25 37.73s9.13.05 11.7 1.62c.38.24.58.53.85.87a3 3 0 0 0-1.15-1.6C33.65 37.15 25 37.16 25 37.16z" opacity=".25"/>`,
|
||||
bb: `<path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25 5.77c-2.1 0-3.81.88-3.81 1.96l1.51 2.65C6.65 24.47 17 37.52 17 37.52h16s7.05-8.68.77-19.51l-3 4.82c-.66 1.09-1.96 1.5-2.9.91-.93-.57-1.14-1.91-.47-3l3.89-6.27a35.38 35.38 0 0 0-4-4.09l1.52-2.65c0-1.08-1.7-1.96-3.8-1.96z"/><path d="M25 5.77c-.82 0-1.57.13-2.2.36 4.35.84 4.99 1.12 2.57 4.35l3.24 3.56c-3.65 8.24-1.6 8-1.6 8s.7-2.65 4.11-7.77a35.7 35.7 0 0 0-3.82-3.89l1.51-2.65c0-1.08-1.7-1.96-3.81-1.96M33.77 18l-1.01 1.52c3.73 8.41-4.14 18-4.14 18H33c.16.03 6.96-8.85.77-19.52" opacity=".18"/><path fill="#fff" d="M15.14 31.72c-.22-.03-3.42-9.78 5.76-18.75-2.3 1.9-7.14 13.16-5.75 18.75zM23.3 10.2l-1.47-2.6s.24-.72 1.78-1.05c-1.73 1.35-1 1.67-.3 3.65z" opacity=".25"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25 36.46s-9.13.04-11.7 1.62c-1.72 1.06-2.13 3.65-1.9 6.32h27.2c.23-2.67-.18-5.26-1.9-6.32C34.12 36.5 25 36.46 25 36.46z"/><path fill="#fff" d="M25 37.15S16.29 37 13.38 38.8c-.37.23-.7.84-.96 1.4.26-.34.5-.62.89-.86 2.56-1.56 11.69-1.6 11.69-1.6s9.13.04 11.7 1.61c.38.24.58.54.85.87a3 3 0 0 0-1.15-1.6C33.65 37.15 25 37.16 25 37.16z" opacity=".25"/>`,
|
||||
bn: `<path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25.192 23.015c-.1654 6.9672-11.758 5.2189-11.516 18.104l22.86.1184c-2.094-6.442 9.69-25.16-11.931-32.258v-.0001s-2.4381-2.601-5.9655-2.8237l.2227 3.5347-4.5583 4.5816c-2.6294 3.1455-8.7347 8.3784-7.7513 9.6111 3.1158 5.3041 6.3306 4.4316 6.3306 4.4316 4.2418-4.5433 5.8193-2.0894 12.309-5.2997z"/><path d="M19.32 14.694c-.7757.8609-.6902 1.1156-.8137 2.1503.8055.1232 1.5069.2398 2.2486.0656 2.3809-1.262.075-3.4026-1.4347-2.2162z" opacity=".4" paint-order="fill markers stroke"/><path d="M9.1916 22.166c-.8496.4078-.9984.9608-1.0565 1.4754.7288.4181 1.8765-.1255 2.0412-1.4316l-.9846-.044z" opacity=".35"/><path fill="#fff" d="M8.1905 25.15s.6525 1.1374-1.1019-1.641c.6594-1.9774 8.263-9.0796 12.438-13.534l-.1836-3.0857s1.0689 1.6901 1.2475 3.468c-4.3898 4.39-12.22 10.833-12.824 13.213.023.6738.24 1.0278.4231 1.5797z" opacity=".25"/><path d="M13.26 28.257c2.0291-3.3367 8.3914-3.2239 11.932-5.2424.3228.1024.1304 1.3697.2398 1.23.8476-1.0903 2.9259-3.279.8684-6.8743.5214 5.9575-13.718 5.5912-15.89 10.305-.2005.4355 2.1818.7932 2.85.5818z" opacity=".18"/><path fill="#fff" d="M25.8 23.781c-1.0131 5.8132-9.5449 6.1169-10.988 12.641 2.8332-6.4058 10.762-5.7136 10.988-12.641" opacity=".25"/><path d="M18.64 6.1556s3.051.738 4.9045 3.9825c20.499 7.1536 7.6413 27.937 5.7883 31.073l7.2034.026c-1.9871-3.2431 9.5482-25.597-11.931-32.258-1.7757-1.0691-2.7677-2.6092-5.9655-2.8238z" opacity=".18"/><path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M25 36.457s-9.1309.048-11.691 1.6192c-1.7273 1.0602-2.1348 3.6514-1.8998 6.3237h27.182c.235-2.6723-.1725-5.2636-1.8999-6.3237-2.5597-1.5711-11.691-1.6192-11.691-1.6192z"/><path fill="#fff" d="M25 37.147s-8.7121-.1373-11.624 1.6658c-.3698.2291-.6992.8394-.9536 1.3902.2608-.3313.5022-.613.8867-.849 2.5598-1.5711 11.691-1.6191 11.691-1.6191s9.1318.048 11.692 1.6191c.391.24.5924.5316.8556.8701.026-.076-.4084-1.1578-1.1438-1.5962-2.7554-1.492-11.403-1.4808-11.403-1.4808z" opacity=".25"/>`,
|
||||
bp: `<path fill="#5f5955" stroke="#1e1e1e" stroke-linejoin="round" stroke-width="1.2" d="M21.503 27.594h6.994M19 17.508a6.35 6.35 0 0 0 1.966 4.587l-3.65 2.1.43 3.399h4.306c-.794 3.559-2.755 7.33-5.062 8.617s-5.3 3.097-4.843 8.189h25.706c.457-5.092-2.535-6.902-4.842-8.189-2.307-1.286-4.268-5.058-5.062-8.617h4.306l.43-3.4-3.65-2.099a6.352 6.352 0 0 0 1.966-4.587c0-3.367-2.628-5.912-6-5.912-3.373 0-6.002 2.545-6.001 5.912z"/><path d="M24.962 11.537c1.17-.459 9.527 5.906.647 10.773l4.512 2.1-.562 3.125h2.659l.428-3.399-3.65-2.1c1.253-1.2 1.962-2.58 1.964-4.312-.468-5.416-5.998-6.186-5.998-6.186zm-2.949 15.998c4.503 7.934 9.47 9.994 13.074 9.965l-2.115-1.347c-2.075-1.49-4.732-4.858-5.062-8.618z" opacity=".18"/><path fill="#fff" d="m21.983 22.213-1.647 2.347-2.356-.014 4.013-2.324zm2.324-9.946c-2.542.138-5.73 3.173-4.385 6.918l.199.643c-.33-3.489 2.127-7.116 4.186-7.561m-6.444 25.358c-3.984 2.305-5.117 6.14-5.117 6.14-.01 0-.548-4.175 3.956-6.654s4.822-6.15 5.86-8.893c-.636 3.704-.715 7.102-4.699 9.407" opacity=".25"/>`,
|
||||
},
|
||||
},
|
||||
caliente: {
|
||||
license: "CC BY-NC-SA 4.0 (avi)",
|
||||
vbX: 0,
|
||||
vbY: 0,
|
||||
vbW: 16.933,
|
||||
vbH: 16.933,
|
||||
pieces: {
|
||||
wk: `<defs><linearGradient id="a"><stop offset="0" stop-color="#fff"/></linearGradient><linearGradient id="b" gradientTransform="translate(0 2.117)"><stop offset="0" stop-color="#ccc"/></linearGradient><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="i" x1="25.929" x2="31.75" y1="8.599" y2="8.599" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="21.696" x2="30.163" y1="12.965" y2="12.965" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="f" x1="92.075" x2="98.954" y1="8.599" y2="8.599" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="23.283" x2="28.575" y1="7.144" y2="7.144" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="j" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.215 0 0 1 -2.405 0)"/><g transform="translate(-17.462)"><path fill="#a6a6a6" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M25.9291 1.323v3.4395" style="font-variation-settings:normal"/><ellipse cx="25.929" cy="7.144" fill="url(#e)" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" rx="2.117" ry="2.91" style="font-variation-settings:normal"/><path fill="url(#f)" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M94.7208 11.1125s-2.1166-1.0583-2.1166-3.175c0-1.0583 1.001-2.1167 2.1166-2.1167 2.1167 0 3.7042 1.5875 3.7042 5.5563" style="font-variation-settings:normal" transform="translate(-72.496)"/><path fill="url(#g)" d="M21.9604 11.1125s-.2646 2.1167-.2646 3.175c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-3.175-.2646-3.175-1.323.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292"/><path fill="url(#h)" d="m29.3687 11.1125.7938 3.175-1.852.2646-.5293-2.9104z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="url(#i)" d="M29.6333 11.1125s2.1167-1.0583 2.1167-3.175c0-1.0583-1.001-2.1167-2.1167-2.1167-2.1167 0-3.7042 1.5875-3.7042 5.5563" style="font-variation-settings:normal"/><path fill="url(#j)" d="M30.1625 6.35c0 2.6458-.7938 3.7042-2.1167 4.7625 2.1167 0 3.7042-1.852 3.7042-3.175 0-.882-1.5876-1.5875-1.5876-1.5875" style="font-variation-settings:normal"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M22.225 11.1125s-.5292 2.1167-.5292 3.175c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.5292-3.175-.5292-3.175-1.323.5292-3.7042.5292-3.7042.5292s-2.3812 0-3.7042-.5292"/><path fill="#a6a6a6" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M24.8708 2.3813h2.1167" style="font-variation-settings:normal"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="M29.8979 12.7c-1.323.5292-3.9688.5292-3.9688.5292s-2.6458 0-3.9687-.5292"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M29.6333 11.1125s2.1167-1.0583 2.1167-3.175c0-1.0583-1.001-2.1167-2.1167-2.1167-2.1167 0-3.7042 1.5875-3.7042 5.5563" style="font-variation-settings:normal"/></g>`,
|
||||
wq: `<defs><linearGradient id="a"><stop offset="0" stop-color="#fff"/></linearGradient><linearGradient id="b" gradientTransform="translate(0 2.117)"><stop offset="0" stop-color="#ccc"/></linearGradient><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="k" x1="76.2" x2="84.667" y1="12.965" y2="12.965" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="j" x1="19.05" x2="22.225" y1="5.821" y2="5.821" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="i" x1="29.633" x2="32.808" y1="5.821" y2="5.821" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="h" x1="26.458" x2="29.633" y1="3.44" y2="3.44" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="22.225" x2="25.4" y1="3.44" y2="3.44" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="20.637" x2="31.221" y1="8.202" y2="8.202" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="l" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.215 0 0 1 -2.405 0)"/><g transform="translate(-17.462)"><path fill="url(#e)" d="m22.7541 12.9646-2.1166-7.1438c3.175 2.9104 3.175 1.323 3.175-2.3812 2.1166 2.6458 2.1166 2.6458 4.2333 0 0 3.7042-.013 5.3096 3.175 2.3812l-2.1167 7.1438" style="font-variation-settings:normal"/><path fill="url(#f)" d="m29.1041 12.9646-2.1166.2646s1.0583-2.6459 1.0583-6.8792c.7938 1.0583 2.3813 1.0583 2.3813 1.0583s-1.5875 3.7042-1.323 5.5563" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="m22.225 11.1125-1.5875-5.2917c3.175 2.9105 3.175 1.323 3.175-2.3812 2.1166 3.4396 2.1166 3.4396 4.2333 0 0 3.7042-.013 5.3096 3.175 2.3812l-1.5875 5.2917" style="font-variation-settings:normal"/><circle cx="23.812" cy="3.44" r="1.058" fill="url(#g)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal;-inkscape-stroke:none"/><circle cx="28.046" cy="3.44" r="1.058" fill="url(#h)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal;-inkscape-stroke:none"/><circle cx="31.221" cy="5.821" r="1.058" fill="url(#i)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal;-inkscape-stroke:none"/><circle cx="20.637" cy="5.821" r="1.058" fill="url(#j)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="url(#k)" d="M76.4646 11.1125s-.2646 2.1167-.2646 3.175c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-3.175-.2646-3.175-1.3229.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292" transform="translate(-54.504)"/><path fill="url(#l)" d="m83.873 11.1125.7937 3.175-1.852.2646-.5293-2.9104z" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-54.504)"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M22.225 11.1125s-.5292 2.1167-.5291 3.175c1.5875.5292 4.2333.5292 4.2333.5292s2.6458 0 4.2333-.5292c0-1.0583-.5292-3.175-.5292-3.175-1.323.5292-3.7042.5292-3.7042.5292s-2.3812 0-3.7041-.5292"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="M29.8979 12.7c-1.323.5292-3.9688.5292-3.9688.5292s-2.6458 0-3.9687-.5292"/></g>`,
|
||||
wr: `<defs><linearGradient id="a"><stop offset="0" stop-color="#fff"/></linearGradient><linearGradient id="b" gradientTransform="translate(0 2.117)"><stop offset="0" stop-color="#ccc"/></linearGradient><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="i" x1="4.498" x2="12.435" y1="5.806" y2="5.806" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="4.233" x2="12.7" y1="13.758" y2="13.758" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="41.275" x2="47.625" y1="9.79" y2="9.79" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="j" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.215 0 0 1 -2.405 0)"/><path fill="url(#e)" d="m41.275 12.9646.5292-6.35h5.2916l.5292 6.35" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="url(#f)" d="m9.525 6.8792.5291 6.35 1.5875-.2646-.5291-6.35z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="2.4" stroke-width="1.058" d="m41.275 12.7.5292-5.027m5.2916 0 .5292 5.027" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="url(#g)" d="M4.4979 12.7s-.2646.5292-.2646 1.5875c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.323.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292"/><path fill="url(#h)" d="m12.4354 12.7.2646 1.5875-1.8521.2646-.2646-1.323z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M4.498 12.7s-.2647.5292-.2646 1.5875c1.5875.5292 4.2333.5292 4.2333.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.3229.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292"/><path fill="url(#i)" d="M5.2916 3.9688S4.498 6.35 4.498 7.4083c1.5875.5292 3.9688 0 3.9688 0s2.3812.5292 3.9687 0c0-1.0583-.5292-3.4395-.5292-3.4395-1.3229.5291-3.4396.2645-3.4396.2645s-1.852.2646-3.175-.2645"/><path fill="url(#j)" d="m11.9062 5.2917.2646 2.1166-.7937.7938-.5292-4.2334h.7937" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M4.7625 3.9688S4.4979 6.35 4.4979 7.4083c1.323.5292 3.9688.5292 3.9688.5292s2.6458 0 3.9687-.5292c0-1.0583-.2646-3.4395-.2646-3.4395z"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="m7.1437 3.7042-.1466 2.3812m2.6342-2.3812.1466 2.3812" class="UnoptimicedTransforms" style="font-variation-settings:normal" transform="translate(.08 .007)"/>`,
|
||||
wb: `<defs><linearGradient id="a"><stop offset="0" stop-color="#fff"/></linearGradient><linearGradient id="b" gradientTransform="translate(0 2.117)"><stop offset="0" stop-color="#ccc"/></linearGradient><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="i" x1="6.879" x2="10.054" y1="2.91" y2="2.91" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="4.763" x2="12.171" y1="13.758" y2="13.758" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="40.766" x2="48.134" y1="8.202" y2="8.202" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="h" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.094 0 0 1 -1.265 0)"/><path fill="url(#e)" d="M42.3333 12.9646c-4.4979-5.5563 2.1167-9.525 2.1167-9.525s6.6146 3.9687 2.1167 9.525" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="url(#f)" d="M8.202 4.2333c2.1167.7938 3.7042 6.6146-.2645 8.9959l2.6458-.2646c4.2334-4.498.5292-8.4667-2.1166-8.9959Z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="2.4" stroke-width="1.058" d="M42.3333 12.9646C37.8354 6.8792 44.45 3.9687 44.45 3.9687s6.6146 2.9105 2.1167 8.9959" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="url(#g)" d="M5.027 12.7s-.2645.5292-.2645 1.5875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.3229.5292-3.4396.5292-3.4396.5292s-2.1166 0-3.4395-.5292"/><path fill="url(#h)" d="m11.9062 12.7.2646 1.5875-1.852.2646-.2646-1.323z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M5.027 12.7s-.2645.5292-.2645 1.5875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.3229.5292-3.4396.5292-3.4396.5292s-2.1166 0-3.4395-.5292"/><circle cx="8.467" cy="2.91" r="1.058" fill="url(#i)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="2.4" stroke-width="1.058" d="M8.4667 3.9688c3.175 1.5875 1.0583 3.7041 0 4.7625" style="font-variation-settings:normal"/>`,
|
||||
wn: `<defs><linearGradient id="a"><stop offset="0" stop-color="#fff"/></linearGradient><linearGradient id="b" gradientTransform="translate(0 2.117)"><stop offset="0" stop-color="#ccc"/></linearGradient><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="e" x1="4.085" x2="13.229" y1="8.864" y2="8.864" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="f" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9922 2.646 1.3229 4.2334 1.3229 1.5875 0 4.2333-.3307 4.2333-1.323 0-.9921-2.7787-1.3229-4.2333-1.3229s-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.094 0 0 1 -1.265 0)"/><path fill="url(#e)" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c-.7937-2.3813 1.0584-3.9688 1.0584-6.35 0-3.9688-4.7625-5.027-4.7625-5.027v1.3228s-3.9688 2.9104-4.2334 3.175-.1183.5571 0 .7938c.2646.5291.5292.7937.7938 1.0583q.3968.3969.7937 0c.2646-.2646.7938-.5292 1.0584-.5292.2645 0 1.0583.2646 1.3229.2646s1.3229-.2646 1.852-.7937C8.7313 9.525 7.9376 9.525 7.1438 10.054c-.7937.5292-2.3812 2.1167-2.3812 4.2334"/><path fill="url(#f)" d="m-6.0854 12.7.2645 1.5875-2.3812.5292c-.5292-3.9688 2.9104-4.2334 2.9104-6.8792.7938.7937-.7937 4.7625-.7937 4.7625" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(17.992)"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c-.7937-2.3813 1.0584-3.9688 1.0584-6.35 0-3.9688-4.7625-5.027-4.7625-5.027v1.3228s-3.9688 2.9104-4.2334 3.175-.1183.5571 0 .7938c.2646.5291.5292.7937.7938 1.0583q.3968.3969.7937 0c.2646-.2646.7938-.5292 1.0584-.5292.2645 0 1.0583.2646 1.3229.2646s1.3229-.2646 1.852-.7937C8.7313 9.525 7.9376 9.525 7.1438 10.054c-.7937.5292-2.3812 2.1167-2.3812 4.2334"/><circle cx="8.731" cy="6.085" r=".529"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="M11.9062 12.7c-1.3229.5292-3.4395.5292-3.4395.5292s-2.1167 0-3.4396-.5292"/>`,
|
||||
wp: `<defs><linearGradient id="b"><stop offset="0" stop-color="#fff"/></linearGradient><linearGradient id="c" gradientTransform="translate(0 2.117)"><stop offset="0" stop-color="#ccc"/></linearGradient><linearGradient id="a"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="4.763" x2="12.171" y1="9.525" y2="9.525" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" gradientTransform="translate(0 2.117)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9922 2.646 1.3229 4.2334 1.3229 1.5875 0 4.2333-.3307 4.2333-1.323 0-.9921-2.7787-1.3229-4.2333-1.3229s-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.094 0 0 1 -1.265 0)"/><path fill="url(#e)" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-2.1167-.7937-2.6458-2.3812-4.2333 1.5875-1.5875.7937-2.6459-.2646-3.7042.7937-.7938 0-2.1167-1.0583-2.1167S6.6146 5.5563 7.4083 6.35c-1.0583 1.0583-1.852 2.1167-.2646 3.7042-1.5875 1.5875-2.3812 2.1166-2.3812 4.2333"/><path fill="url(#f)" d="m12.1708 14.2875-1.852.5292c0-.836-.2477-1.6307-.531-2.3352-.434-1.0793-.952-1.947-.792-2.4273.2646-.7938.2646-1.0584.2646-1.5875 0-1.0584-.7937-1.323-.5292-2.1167.2646-.7938 0-1.5875-.7937-1.852 1.323-.5293 1.6436 1.3656 1.852 1.852.3387.79 1.0584 2.3812 0 3.7042 1.8522 1.5875 2.3813 3.7217 2.3813 4.2333"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-2.1167-.7937-2.6458-2.3812-4.2333 1.5875-1.5875.7937-2.6459-.2646-3.7042.7937-.7938 0-2.1167-1.0583-2.1167S6.6146 5.5563 7.4083 6.35c-1.0583 1.0583-1.852 2.1167-.2646 3.7042-1.5875 1.5875-2.3812 2.1166-2.3812 4.2333"/>`,
|
||||
bk: `<defs><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="b" gradientTransform="scale(.07)"><stop offset="0" stop-color="#8c8c8c"/></linearGradient><linearGradient id="a" gradientTransform="matrix(.07 0 0 .07 -53.975 2.117)"><stop offset="0" stop-color="#595959"/></linearGradient><linearGradient id="i" x1="25.929" x2="31.75" y1="8.599" y2="8.599" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="21.696" x2="30.163" y1="12.965" y2="12.965" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="f" x1="92.075" x2="98.954" y1="8.599" y2="8.599" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="23.283" x2="28.575" y1="7.144" y2="7.144" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="j" x1="28.046" x2="31.75" y1="8.463" y2="8.463" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" x1="27.781" x2="30.162" y1="12.832" y2="12.832" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.215 0 0 1 -2.405 0)"/><g transform="matrix(-1 0 0 1 34.396 0)"><path fill="#a6a6a6" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M25.9291 1.323v3.4395" style="font-variation-settings:normal"/><ellipse cx="25.929" cy="7.144" fill="url(#e)" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" rx="2.117" ry="2.91" style="font-variation-settings:normal"/><path fill="url(#f)" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M94.7208 11.1125s-2.1166-1.0583-2.1166-3.175c0-1.0583 1.001-2.1167 2.1166-2.1167 2.1167 0 3.7042 1.5875 3.7042 5.5563" style="font-variation-settings:normal" transform="translate(-72.496)"/><path fill="url(#g)" d="M21.9604 11.1125s-.2646 2.1167-.2646 3.175c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-3.175-.2646-3.175-1.323.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292"/><path fill="url(#h)" d="m29.3687 11.1125.7938 3.175-1.852.2646-.5293-2.9104z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="url(#i)" d="M29.6333 11.1125s2.1167-1.0583 2.1167-3.175c0-1.0583-1.001-2.1167-2.1167-2.1167-2.1167 0-3.7042 1.5875-3.7042 5.5563" style="font-variation-settings:normal"/><path fill="url(#j)" d="M28.0458 6.6146c1.852.2646 2.9104 2.6458 1.5875 4.2333C31.75 10.0542 31.75 9.2604 31.75 7.9375c0-3.175-3.7042-1.323-3.7042-1.323" style="font-variation-settings:normal"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M29.6333 11.1125s2.1167-1.0583 2.1167-3.175c0-1.0583-1.001-2.1167-2.1167-2.1167-2.1167 0-3.7042 1.5875-3.7042 5.5563" style="font-variation-settings:normal"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M22.225 11.1125s-.5292 2.1167-.5292 3.175c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.5292-3.175-.5292-3.175-1.323.5292-3.7042.5292-3.7042.5292s-2.3812 0-3.7042-.5292"/><path fill="#a6a6a6" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M24.8708 2.3813h2.1167" style="font-variation-settings:normal"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="M29.8979 12.7c-1.323.5292-3.9688.5292-3.9688.5292s-2.6458 0-3.9687-.5292"/></g>`,
|
||||
bq: `<defs><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="b" gradientTransform="scale(.07)"><stop offset="0" stop-color="#8c8c8c"/></linearGradient><linearGradient id="a" gradientTransform="matrix(.07 0 0 .07 -53.975 2.117)"><stop offset="0" stop-color="#595959"/></linearGradient><linearGradient id="k" x1="76.2" x2="84.667" y1="12.965" y2="12.965" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="20.637" x2="31.221" y1="8.202" y2="8.202" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="l" x1="82.285" x2="84.667" y1="12.832" y2="12.832" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="j" x1="19.05" x2="22.225" y1="5.821" y2="5.821" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="i" x1="29.633" x2="32.808" y1="5.821" y2="5.821" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" x1="26.458" x2="29.633" y1="3.44" y2="3.44" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="g" x1="22.225" x2="25.4" y1="3.44" y2="3.44" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" x1="26.988" x2="30.427" y1="9.79" y2="9.79" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.215 0 0 1 -2.405 0)"/><g transform="matrix(-1 0 0 1 34.396 0)"><path fill="url(#e)" d="m22.7541 12.9646-2.1166-7.1438c3.175 2.9104 3.175 1.323 3.175-2.3812 2.1166 2.6458 2.1166 2.6458 4.2333 0 0 3.7042-.013 5.3096 3.175 2.3812l-2.1167 7.1438" style="font-variation-settings:normal"/><path fill="url(#f)" d="m29.1041 12.9646-2.1166.2646s1.0583-2.6459 1.0583-6.8792c.7938 1.0583 2.3813 1.0583 2.3813 1.0583s-1.5875 3.7042-1.323 5.5563" style="font-variation-settings:normal"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="m22.225 11.1125-1.5875-5.2917c3.175 2.9105 3.175 1.323 3.175-2.3812 2.1166 3.4396 2.1166 3.4396 4.2333 0 0 3.7042-.013 5.3096 3.175 2.3812l-1.5875 5.2917" style="font-variation-settings:normal"/><circle cx="23.813" cy="3.44" r="1.058" fill="url(#g)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal"/><circle cx="28.046" cy="3.44" r="1.058" fill="url(#h)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal"/><circle cx="31.221" cy="5.821" r="1.058" fill="url(#i)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal"/><circle cx="20.637" cy="5.821" r="1.058" fill="url(#j)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal"/><path fill="url(#k)" d="M76.4646 11.1125s-.2646 2.1167-.2646 3.175c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-3.175-.2646-3.175-1.3229.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292" transform="translate(-54.504)"/><path fill="url(#l)" d="m83.873 11.1125.7937 3.175-1.852.2646-.5293-2.9104z" style="font-variation-settings:normal" transform="translate(-54.504)"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M22.225 11.1125s-.5292 2.1167-.5291 3.175c1.5875.5292 4.2333.5292 4.2333.5292s2.6458 0 4.2333-.5292c0-1.0583-.5292-3.175-.5292-3.175-1.323.5292-3.7042.5292-3.7042.5292s-2.3812 0-3.7041-.5292"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="M29.8979 12.7c-1.323.5292-3.9688.5292-3.9688.5292s-2.6458 0-3.9687-.5292"/></g>`,
|
||||
br: `<defs><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="b" gradientTransform="scale(.07)"><stop offset="0" stop-color="#8c8c8c"/></linearGradient><linearGradient id="a" gradientTransform="matrix(.07 0 0 .07 -53.975 2.117)"><stop offset="0" stop-color="#595959"/></linearGradient><linearGradient id="i" x1="4.498" x2="12.435" y1="5.806" y2="5.806" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="4.233" x2="12.7" y1="13.758" y2="13.758" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="41.275" x2="47.625" y1="9.79" y2="9.79" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="j" x1="10.848" x2="12.171" y1="6.085" y2="6.085" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" x1="10.583" x2="12.7" y1="13.626" y2="13.626" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" x1="9.525" x2="11.642" y1="9.922" y2="9.922" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.215 0 0 1 -2.405 0)"/><path fill="url(#e)" d="m41.275 12.9646.5292-6.35h5.2916l.5292 6.35" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(-1 0 0 1 52.917 0)"/><path fill="url(#f)" d="m9.525 6.8792.5291 6.35 1.5875-.2646-.5291-6.35z" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(-1 0 0 1 16.933 0)"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="2.4" stroke-width="1.058" d="m41.275 12.7.5292-5.027m5.2916 0 .5292 5.027" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(-1 0 0 1 52.917 0)"/><path fill="url(#g)" d="M4.4979 12.7s-.2646.5292-.2646 1.5875c1.5875.5292 4.2334.5292 4.2334.5292s2.6458 0 4.2333-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.323.5292-3.9687.5292-3.9687.5292s-2.6459 0-3.9688-.5292" transform="matrix(-1 0 0 1 16.933 0)"/><path fill="url(#h)" d="m12.4354 12.7.2646 1.5875-1.8521.2646-.2646-1.323z" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(-1 0 0 1 16.933 0)"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M12.4355 12.7s.2646.5292.2645 1.5875c-1.5875.5292-4.2333.5292-4.2333.5292s-2.6458 0-4.2333-.5292c0-1.0583.2646-1.5875.2646-1.5875 1.3229.5292 3.9687.5292 3.9687.5292s2.6459 0 3.9688-.5292"/><path fill="url(#i)" d="M5.2916 3.9688S4.498 6.35 4.498 7.4083c1.5875.5292 3.9688 0 3.9688 0s2.3812.5292 3.9687 0c0-1.0583-.5292-3.4395-.5292-3.4395-1.3229.5291-3.4396.2645-3.4396.2645s-1.852.2646-3.175-.2645" transform="matrix(-1 0 0 1 16.933 0)"/><path fill="url(#j)" d="m11.9062 5.2917.2646 2.1166-.7937.7938-.5292-4.2334h.7937" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(-1 0 0 1 16.933 0)"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M12.1708 3.9688s.2647 2.3812.2647 3.4395c-1.323.5292-3.9688.5292-3.9688.5292s-2.6458 0-3.9687-.5292c0-1.0583.2645-3.4396.2645-3.4396z"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="m7.1437 3.7042-.1466 2.3812m2.6342-2.3812.1466 2.3812" class="UnoptimicedTransforms" style="font-variation-settings:normal" transform="matrix(-1 0 0 1 16.854 .007)"/>`,
|
||||
bb: `<defs><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="b" gradientTransform="scale(.07)"><stop offset="0" stop-color="#8c8c8c"/></linearGradient><linearGradient id="a" gradientTransform="matrix(.07 0 0 .07 -53.975 2.117)"><stop offset="0" stop-color="#595959"/></linearGradient><linearGradient id="g" x1="4.763" x2="12.171" y1="13.758" y2="13.758" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="e" x1="40.766" x2="48.134" y1="8.202" y2="8.202" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="i" x1="6.879" x2="10.054" y1="2.91" y2="2.91" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="h" x1="4.762" x2="6.879" y1="13.626" y2="13.626" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" x1="40.412" x2="44.979" y1="8.599" y2="8.599" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9921 2.646 1.3229 4.2334 1.3229s4.2333-.3308 4.2333-1.323-2.7788-1.3229-4.2333-1.3229-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.094 0 0 1 -1.265 0)"/><path fill="url(#e)" d="M42.3333 12.9646c-4.4979-5.5563 2.1167-9.525 2.1167-9.525s6.6146 3.9687 2.1167 9.525" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="url(#f)" d="M44.7146 4.2333c-1.8521 2.1167-3.7042 6.6146.2646 8.9959l-2.6459-.2646C38.1 8.4666 41.8042 4.4979 44.45 3.9687Z" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="2.4" stroke-width="1.058" d="M42.3333 12.9646C37.8354 6.8792 44.45 3.9687 44.45 3.9687s6.6146 2.9105 2.1167 8.9959" style="font-variation-settings:normal;-inkscape-stroke:none" transform="translate(-35.983)"/><path fill="url(#g)" d="M5.027 12.7s-.2645.5292-.2645 1.5875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.3229.5292-3.4396.5292-3.4396.5292s-2.1166 0-3.4395-.5292"/><path fill="url(#h)" d="m5.027 12.7-.2645 1.5875 1.852.2646.2647-1.323z" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M5.027 12.7s-.2645.5292-.2645 1.5875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-1.0583-.2646-1.5875-.2646-1.5875-1.3229.5292-3.4396.5292-3.4396.5292s-2.1166 0-3.4395-.5292"/><circle cx="8.467" cy="2.91" r="1.058" fill="url(#i)" stroke="#000" stroke-linejoin="round" stroke-width="1.058" style="font-variation-settings:normal;-inkscape-stroke:none"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="2.4" stroke-width="1.058" d="M8.4667 3.9688c3.175 1.5875 1.0583 3.7041 0 4.7625" style="font-variation-settings:normal"/>`,
|
||||
bn: `<defs><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="b" gradientTransform="scale(.07)"><stop offset="0" stop-color="#8c8c8c"/></linearGradient><linearGradient id="a" gradientTransform="matrix(.07 0 0 .07 -53.975 2.117)"><stop offset="0" stop-color="#595959"/></linearGradient><linearGradient id="e" x1="4.085" x2="13.229" y1="8.864" y2="8.864" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="g" x1="4.762" x2="10.583" y1="11.509" y2="11.509" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="f" x1="4.233" x2="9.898" y1="5.953" y2="5.953" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9922 2.646 1.3229 4.2334 1.3229 1.5875 0 4.2333-.3307 4.2333-1.323 0-.9921-2.7787-1.3229-4.2333-1.3229s-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.094 0 0 1 -1.265 0)"/><path fill="url(#e)" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c-.7937-2.3813 1.0584-3.9688 1.0584-6.35 0-3.9688-4.7625-5.027-4.7625-5.027v1.3228s-3.9688 2.9104-4.2334 3.175-.1183.5571 0 .7938c.2646.5291.5292.7937.7938 1.0583q.3968.3969.7937 0c.2646-.2646.7938-.5292 1.0584-.5292.2645 0 1.0583.2646 1.3229.2646s1.3229-.2646 1.852-.7937C8.7313 9.525 7.9376 9.525 7.1438 10.054c-.7937.5292-2.3812 2.1167-2.3812 4.2334"/><path fill="url(#f)" d="M8.4667 3.175c1.3229 1.0583 1.852 1.852 1.0583 2.3812s-2.6458.7938-4.7625 3.175l-.5292-1.3229 4.2334-3.175"/><path fill="url(#g)" d="m6.6146 14.8167-1.8521-.5292c0-2.1167 1.5875-3.9688 2.3812-4.498.7938-.529 2.1167-.2645 3.4396-1.5874 0 .5291-1.852 1.852-2.3812 2.3812-.7938.7938-1.5875 2.6459-1.5875 4.2334"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c-.7937-2.3813 1.0584-3.9688 1.0584-6.35 0-3.9688-4.7625-5.027-4.7625-5.027v1.3228s-3.9688 2.9104-4.2334 3.175-.1183.5571 0 .7938c.2646.5291.5292.7937.7938 1.0583q.3968.3969.7937 0c.2646-.2646.7938-.5292 1.0584-.5292.2645 0 1.0583.2646 1.3229.2646s1.3229-.2646 1.852-.7937C8.7313 9.525 7.9376 9.525 7.1438 10.054c-.7937.5292-2.3812 2.1167-2.3812 4.2334"/><circle cx="8.731" cy="6.085" r=".529"/><path fill="none" stroke="#000" stroke-linejoin="round" stroke-width="1.058" d="M11.9062 12.7c-1.3229.5292-3.4395.5292-3.4395.5292s-1.8521 0-3.175-.5292"/>`,
|
||||
bp: `<defs><linearGradient id="c"><stop offset="0" stop-opacity=".2"/></linearGradient><linearGradient id="b" gradientTransform="scale(.07)"><stop offset="0" stop-color="#8c8c8c"/></linearGradient><linearGradient id="a" gradientTransform="matrix(.07 0 0 .07 -53.975 2.117)"><stop offset="0" stop-color="#595959"/></linearGradient><linearGradient id="e" x1="4.763" x2="12.171" y1="9.525" y2="9.525" gradientUnits="userSpaceOnUse" href="#a"/><linearGradient id="f" x1="4.763" x2="8.996" y1="9.611" y2="9.611" gradientUnits="userSpaceOnUse" href="#b"/><linearGradient id="d" x1="4.233" x2="103.049" y1="24.342" y2="24.342" gradientTransform="matrix(1 0 0 1.25 .794 -3.043)" gradientUnits="userSpaceOnUse" href="#c"/></defs><path fill="url(#d)" d="M5.027 14.8167c0 .9922 2.646 1.3229 4.2334 1.3229 1.5875 0 4.2333-.3307 4.2333-1.323 0-.9921-2.7787-1.3229-4.2333-1.3229s-4.2333.3308-4.2333 1.323" class="UnoptimicedTransforms" style="font-variation-settings:normal;-inkscape-stroke:none" transform="matrix(1.094 0 0 1 -1.265 0)"/><path fill="url(#e)" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-2.1167-.7937-2.6458-2.3812-4.2333 1.5875-1.5875.7937-2.6459-.2646-3.7042.7937-.7938 0-2.1167-1.0583-2.1167S6.6146 5.5563 7.4083 6.35c-1.0583 1.0583-1.852 2.1167-.2646 3.7042-1.5875 1.5875-2.3812 2.1166-2.3812 4.2333"/><path fill="url(#f)" d="m4.7625 14.2875 1.852.5292c0-2.1167 1.5876-3.9688 1.323-4.7625s-.2646-1.0584-.2646-1.5875c0-1.0584.7938-1.323.5292-2.1167-.2646-.7938 0-1.5875.7937-1.852-1.3229-.5293-1.6436 1.3656-1.852 1.852-.3386.79-1.0584 2.3812 0 3.7042-1.8521 1.5875-2.3813 3.7217-2.3813 4.2333"/><path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.058" d="M4.7625 14.2875c1.5875.5292 3.7042.5292 3.7042.5292s2.1166 0 3.7041-.5292c0-2.1167-.7937-2.6458-2.3812-4.2333 1.5875-1.5875.7937-2.6459-.2646-3.7042.7937-.7938 0-2.1167-1.0583-2.1167S6.6146 5.5563 7.4083 6.35c-1.0583 1.0583-1.852 2.1167-.2646 3.7042-1.5875 1.5875-2.3812 2.1166-2.3812 4.2333"/>`,
|
||||
},
|
||||
},
|
||||
pixel: {
|
||||
license: "AGPLv3+ (therealqtpi)",
|
||||
vbX: 0,
|
||||
vbY: -0.5,
|
||||
vbW: 16,
|
||||
vbH: 16,
|
||||
pieces: {
|
||||
wk: `<path stroke="#000" d="M7 1h2M6 2h1m2 0h1M6 3h1m2 0h1M3 4h3m1 0h2m1 0h3M2 5h1m3 0h1m2 0h1m3 0h1M2 6h1m2 0h1m1 0h2m1 0h1m2 0h1M2 7h1m3 0h1m2 0h1m3 0h1M2 8h1m1 0h1m6 0h1m1 0h1M3 9h1m1 0h2m2 0h2m1 0h1M3 10h1m8 0h1m-9 1h1m6 0h1m-8 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#fdfdfd" d="M7 2h1M3 5h1"/><path stroke="#a7a7a7" d="M8 2h1M8 3h1m3 2h1m-1 1h1m-1 1h1m-1 1h1m-2 1h1m-2 1h2m-3 1h2m-2 2h3"/><path stroke="#e1e1e1" d="M7 3h1M3 6h1M3 7h1M3 8h1m0 1h1m-1 1h2m-1 1h1m-2 2h2"/><path stroke="#c1c1c1" d="M4 5h2m4 0h2M6 6h1m2 0h1M7 7h2M7 8h2M7 9h2m-3 1h4m-4 1h3m-3 2h3"/>`,
|
||||
wq: `<path stroke="#000" d="M7 1h2M6 2h1m2 0h1M6 3h1m2 0h1M7 4h2M1 5h1m12 0h1M2 6h2m3 0h2m3 0h2M2 7h1m1 0h1m1 0h1m2 0h1m1 0h1m1 0h1M2 8h1m2 0h1m4 0h1m2 0h1M3 9h1m8 0h1M3 10h1m8 0h1m-9 1h1m6 0h1m-8 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#fdfdfd" d="M7 2h1M3 7h1M3 8h1"/><path stroke="#a7a7a7" d="M8 2h1M8 3h1m3 4h1m-2 1h2m-2 1h1m-2 1h2m-3 1h2m-2 2h3"/><path stroke="#e1e1e1" d="M7 3h1M7 7h1M4 8h1m1 0h1M4 9h2m-2 1h2m-1 1h1m-2 2h2"/><path stroke="#c1c1c1" d="M8 7h1M7 8h3M6 9h5m-5 1h4m-4 1h3m-3 2h3"/>`,
|
||||
wr: `<path stroke="#000" d="M3 1h3m1 0h6M3 2h1m1 0h1m1 0h1m4 0h1M3 3h1m1 0h3m4 0h1M3 4h1m8 0h1M3 5h10M4 6h1m6 0h1M5 7h6M5 8h1m4 0h1M5 9h1m4 0h1m-6 1h1m4 0h1m-7 1h1m6 0h1m-9 1h10M2 13h1m10 0h1M2 14h12"/><path stroke="#fdfdfd" d="M4 2h1m0 4h1m-1 5h1m-3 2h3"/><path stroke="#c1c1c1" d="M8 2h3M8 3h3M5 4h5M7 6h2M7 8h2M7 9h2m-2 1h2m-2 1h2m-2 2h3"/><path stroke="#a7a7a7" d="M11 2h1m-1 1h1m-2 1h2M9 6h2M9 8h1M9 9h1m-1 1h1m-1 1h2m-1 2h3"/><path stroke="#e1e1e1" d="M4 3h1M4 4h1m1 2h1M6 8h1M6 9h1m-1 1h1m-1 1h1m-1 2h1"/>`,
|
||||
wb: `<path stroke="#000" d="M7 1h2M6 2h1m2 0h1M6 3h1m2 0h1M7 4h2M6 5h1m2 0h1M5 6h1m4 0h1M4 7h1m4 0h1m1 0h1M4 8h1m3 0h1m2 0h1M4 9h1m6 0h1m-8 1h1m6 0h1m-7 1h1m4 0h1m-7 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#fdfdfd" d="M7 2h1M6 6h1M5 7h1"/><path stroke="#a7a7a7" d="M8 2h1M8 3h1M8 5h1m1 2h1m-1 1h1m-1 1h1m-1 1h1m-2 1h1m-1 2h3"/><path stroke="#e1e1e1" d="M7 3h1M5 8h1M5 9h1m-1 1h1m0 1h1m-3 2h2"/><path stroke="#c1c1c1" d="M7 5h1M7 6h2M6 7h2M6 8h2m1 0h1M6 9h4m-4 1h4m-3 1h2m-3 2h3"/>`,
|
||||
wn: `<path stroke="#000" d="M4 1h4M4 2h1m3 0h3M5 3h1m5 0h1M4 4h1m7 0h1M3 5h1m8 0h1M2 6h1m5 0h1m3 0h1M2 7h1m2 0h3m4 0h1M3 8h2m2 0h1m4 0h1M6 9h1m5 0h1m-8 1h1m5 0h1m-8 1h1m6 0h1m-8 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#fdfdfd" d="M5 2h1m-1 9h1m-2 2h1"/><path stroke="#e1e1e1" d="M6 2h1M6 3h1M5 4h1M4 5h1M3 6h1M3 7h1m4 0h1M8 8h1M7 9h1m-2 1h1m-1 1h1m-2 2h1"/><path stroke="#c1c1c1" d="M7 2h1M7 3h3M7 4h4M5 5h3m1 0h2M4 6h3m2 0h2M4 7h1m4 0h2M9 8h2M8 9h3m-4 1h3m-3 1h2m-3 2h3"/><path stroke="#a7a7a7" d="M10 3h1m0 1h1M8 5h1m2 0h1M7 6h1m3 0h1m-1 1h1m-1 1h1m-1 1h1m-2 1h1m-2 1h2m-2 2h3"/><path stroke="#494949" d="M6 4h1"/>`,
|
||||
wp: `<path stroke="#000" d="M6 2h4M5 3h1m4 0h1M5 4h1m4 0h1M5 5h1m4 0h1M6 6h1m2 0h1M5 7h1m4 0h1M5 8h1m4 0h1M6 9h1m2 0h1m-5 1h1m4 0h1m-7 1h1m6 0h1m-8 1h1m6 0h1m-8 1h8"/><path stroke="#fdfdfd" d="M6 3h1"/><path stroke="#e1e1e1" d="M7 3h2M6 4h1M6 7h2m-2 3h1m-2 1h2m-2 1h1"/><path stroke="#a7a7a7" d="M9 3h1M9 4h1M9 5h1M8 6h1m0 1h1M7 8h3M8 9h1m0 1h1m0 1h1m-5 1h5"/><path stroke="#c1c1c1" d="M7 4h2M6 5h3M7 6h1m0 1h1M6 8h1m0 1h1m-1 1h2m-2 1h3"/>`,
|
||||
bk: `<path stroke="#000" d="M7 1h2M6 2h1m2 0h1M6 3h1m2 0h1M3 4h3m1 0h2m1 0h3M2 5h1m3 0h1m2 0h1m3 0h1M2 6h1m2 0h1m1 0h2m1 0h1m2 0h1M2 7h1m3 0h1m2 0h1m3 0h1M2 8h1m1 0h1m6 0h1m1 0h1M3 9h1m1 0h2m2 0h2m1 0h1M3 10h1m8 0h1m-9 1h1m6 0h1m-8 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#6d6d6d" d="M7 2h1M3 5h1"/><path stroke="#151515" d="M8 2h1M8 3h1m3 2h1m-1 1h1m-1 1h1m-1 1h1m-2 1h1m-2 1h2m-3 1h2m-2 2h3"/><path stroke="#494949" d="M7 3h1M3 6h1M3 7h1M3 8h1m0 1h1m-1 1h2m-1 1h1m-2 2h2"/><path stroke="#242424" d="M4 5h2m4 0h2M6 6h1m2 0h1M7 7h2M7 8h2M7 9h2m-3 1h4m-4 1h3m-3 2h3"/>`,
|
||||
bq: `<path stroke="#000" d="M7 1h2M6 2h1m2 0h1M6 3h1m2 0h1M7 4h2M1 5h1m12 0h1M2 6h2m3 0h2m3 0h2M2 7h1m1 0h1m1 0h1m2 0h1m1 0h1m1 0h1M2 8h1m2 0h1m4 0h1m2 0h1M3 9h1m8 0h1M3 10h1m8 0h1m-9 1h1m6 0h1m-8 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#6d6d6d" d="M7 2h1M3 7h1M3 8h1"/><path stroke="#151515" d="M8 2h1M8 3h1m3 4h1m-2 1h2m-2 1h1m-2 1h2m-3 1h2m-2 2h3"/><path stroke="#494949" d="M7 3h1M7 7h1M4 8h1m1 0h1M4 9h2m-2 1h2m-1 1h1m-2 2h2"/><path stroke="#242424" d="M8 7h1M7 8h3M6 9h5m-5 1h4m-4 1h3m-3 2h3"/>`,
|
||||
br: `<path stroke="#000" d="M3 1h3m1 0h6M3 2h1m1 0h1m1 0h1m4 0h1M3 3h1m1 0h3m4 0h1M3 4h1m8 0h1M3 5h10M4 6h1m6 0h1M5 7h6M5 8h1m4 0h1M5 9h1m4 0h1m-6 1h1m4 0h1m-7 1h1m6 0h1m-9 1h10M2 13h1m10 0h1M2 14h12"/><path stroke="#6d6d6d" d="M4 2h1m0 4h1m-1 5h1m-3 2h1"/><path stroke="#242424" d="M8 2h3M8 3h3M5 4h5M7 6h2M7 8h2M7 9h2m-2 1h2m-2 1h2m-3 2h4"/><path stroke="#151515" d="M11 2h1m-1 1h1m-2 1h2M9 6h2M9 8h1M9 9h1m-1 1h1m-1 1h2m-1 2h3"/><path stroke="#494949" d="M4 3h1M4 4h1m1 2h1M6 8h1M6 9h1m-1 1h1m-1 1h1m-3 2h2"/>`,
|
||||
bb: `<path stroke="#000" d="M7 1h2M6 2h1m2 0h1M6 3h1m2 0h1M7 4h2M6 5h1m2 0h1M5 6h1m4 0h1M4 7h1m4 0h1m1 0h1M4 8h1m3 0h1m2 0h1M4 9h1m6 0h1m-8 1h1m6 0h1m-7 1h1m4 0h1m-7 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#6d6d6d" d="M7 2h1M6 6h1M5 7h1"/><path stroke="#151515" d="M8 2h1M8 3h1M8 5h1m1 2h1m-1 1h1m-1 1h1m-1 1h1m-2 1h1m-1 2h3"/><path stroke="#494949" d="M7 3h1M7 5h1M5 8h1M5 9h1m-1 1h1m0 1h1m-3 2h2"/><path stroke="#242424" d="M7 6h2M6 7h2M6 8h2m1 0h1M6 9h4m-4 1h4m-3 1h2m-3 2h3"/>`,
|
||||
bn: `<path stroke="#000" d="M4 1h4M4 2h1m3 0h3M5 3h1m5 0h1M4 4h1m1 0h1m5 0h1M3 5h1m8 0h1M2 6h1m5 0h1m3 0h1M2 7h1m2 0h3m4 0h1M3 8h2m2 0h1m4 0h1M6 9h1m5 0h1m-8 1h1m5 0h1m-8 1h1m6 0h1m-8 1h8m-9 1h1m8 0h1M3 14h10"/><path stroke="#6d6d6d" d="M5 2h1m-1 9h1"/><path stroke="#494949" d="M6 2h1M6 3h1M5 4h1M4 5h1M3 6h1M3 7h1m4 0h1M8 8h1M7 9h1m-2 1h1m-1 1h1m-3 2h2"/><path stroke="#242424" d="M7 2h1M7 3h3M7 4h4M5 5h3m1 0h2M4 6h3m2 0h2M4 7h1m4 0h2M9 8h2M8 9h3m-4 1h3m-3 1h2m-3 2h3"/><path stroke="#151515" d="M10 3h1m0 1h1M8 5h1m2 0h1M7 6h1m3 0h1m-1 1h1m-1 1h1m-1 1h1m-2 1h1m-2 1h2m-2 2h3"/>`,
|
||||
bp: `<path stroke="#000" d="M6 2h4M5 3h1m4 0h1M5 4h1m4 0h1M5 5h1m4 0h1M6 6h1m2 0h1M5 7h1m4 0h1M5 8h1m4 0h1M6 9h1m2 0h1m-5 1h1m4 0h1m-7 1h1m6 0h1m-8 1h1m6 0h1m-8 1h8"/><path stroke="#6d6d6d" d="M6 3h1M6 7h1m-1 3h1m-2 1h1"/><path stroke="#494949" d="M7 3h2M6 4h1m0 3h1m-2 4h1m-2 1h1"/><path stroke="#151515" d="M9 3h1M9 4h1M9 5h1M8 6h1m0 1h1M7 8h3M8 9h1m0 1h1m0 1h1m-5 1h5"/><path stroke="#242424" d="M7 4h2M6 5h3M7 6h1m0 1h1M6 8h1m0 1h1m-1 1h2m-2 1h3"/>`,
|
||||
},
|
||||
},
|
||||
};
|
||||
4
src/chess/pieces-types.ts
Normal file
4
src/chess/pieces-types.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import type { PieceColor, PieceType } from "../types";
|
||||
|
||||
/** Composite key like "wk", "bp", etc. — used as the lookup key into all SVG sets. */
|
||||
export type PieceKey = `${PieceColor}${PieceType}`;
|
||||
255
src/chess/pieces.ts
Normal file
255
src/chess/pieces.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import type { PieceSet } from "../types";
|
||||
import type { PieceKey } from "./pieces-types";
|
||||
import { PIECE_SETS, type PieceSetData } from "./piece-data.generated";
|
||||
|
||||
export type { PieceKey } from "./pieces-types";
|
||||
|
||||
const UNICODE: Record<PieceKey, string> = {
|
||||
wk: "\u2654",
|
||||
wq: "\u2655",
|
||||
wr: "\u2656",
|
||||
wb: "\u2657",
|
||||
wn: "\u2658",
|
||||
wp: "\u2659",
|
||||
bk: "\u265A",
|
||||
bq: "\u265B",
|
||||
br: "\u265C",
|
||||
bb: "\u265D",
|
||||
bn: "\u265E",
|
||||
bp: "\u265F",
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline SVGs based on the cburnett set (public domain, by Colin M.L. Burnett).
|
||||
* Each glyph is drawn inside a 45x45 viewBox; the board renderer scales them
|
||||
* to fit a square. Stroke/fill colors are baked in so theming the squares
|
||||
* doesn't bleed onto the pieces.
|
||||
*/
|
||||
const CBURNETT: Record<PieceKey, string> = {
|
||||
wk: `<g fill="none" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22.5 11.63V6M20 8h5" stroke-linejoin="miter"/><path d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5" fill="#fff" stroke-linecap="butt" stroke-linejoin="miter"/><path d="M11.5 37c5.5 3.5 15.5 3.5 21 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-3.5-7.5-13-10.5-16-4-3 6 5 10 5 10V37z" fill="#fff"/><path d="M11.5 30c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0"/></g>`,
|
||||
wq: `<g fill="#fff" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 26c8.5-1.5 21-1.5 27 0l2-12-7 11V11l-5.5 13.5-3-15-3 15-5.5-14V25L7 14l2 12z"/><path d="M9 26c0 2 1.5 2 2.5 4 1 1.5 1 1 .5 3.5-1.5 1-1.5 2.5-1.5 2.5-1.5 1.5.5 2.5.5 2.5 6.5 1 16.5 1 23 0 0 0 1.5-1 0-2.5 0 0 .5-1.5-1-2.5-.5-2.5-.5-2 .5-3.5 1-2 2.5-2 2.5-4-8.5-1.5-18.5-1.5-27 0z"/><path d="M11.5 30c3.5-1 18.5-1 22 0M12 33.5c6-1 15-1 21 0" fill="none"/><circle cx="6" cy="12" r="2"/><circle cx="14" cy="9" r="2"/><circle cx="22.5" cy="8" r="2"/><circle cx="31" cy="9" r="2"/><circle cx="39" cy="12" r="2"/></g>`,
|
||||
wr: `<g fill="#fff" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 39h27v-3H9v3zM12 36v-4h21v4H12zM11 14V9h4v2h5V9h5v2h5V9h4v5" stroke-linecap="butt"/><path d="M34 14l-3 3H14l-3-3"/><path d="M31 17v12.5H14V17" stroke-linecap="butt" stroke-linejoin="miter"/><path d="M31 29.5l1.5 2.5h-20l1.5-2.5"/><path d="M11 14h23" fill="none" stroke-linejoin="miter"/></g>`,
|
||||
wb: `<g fill="none" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><g fill="#fff" stroke-linecap="butt"><path d="M9 36c3.39-.97 10.11.43 13.5-2 3.39 2.43 10.11 1.03 13.5 2 0 0 1.65.54 3 2-.68.97-1.65.99-3 .5-3.39-.97-10.11.46-13.5-1-3.39 1.46-10.11.03-13.5 1-1.354.49-2.323.47-3-.5 1.354-1.94 3-2 3-2z"/><path d="M15 32c2.5 2.5 12.5 2.5 15 0 .5-1.5 0-2 0-2 0-2.5-2.5-4-2.5-4 5.5-1.5 6-11.5-5-15.5-11 4-10.5 14-5 15.5 0 0-2.5 1.5-2.5 4 0 0-.5.5 0 2z"/><path d="M25 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 1 1 5 0z"/></g><path d="M17.5 26h10M15 30h15m-7.5-14.5v5M20 18h5" stroke-linejoin="miter"/></g>`,
|
||||
wn: `<g fill="none" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10c10.5 1 16.5 8 16 29H15c0-9 10-6.5 8-21" fill="#fff"/><path d="M24 18c.38 2.91-5.55 7.37-8 9-3 2-2.82 4.34-5 4-1.042-.94 1.41-3.04 0-3-1 0 .19 1.23-1 2-1 0-4.003 1-4-4 0-2 6-12 6-12s1.89-1.9 2-3.5c-.73-.994-.5-2-.5-3 1-1 3 2.5 3 2.5h2s.78-1.992 2.5-3c1 0 1 3 1 3" fill="#fff"/><path d="M9.5 25.5a.5.5 0 1 1-1 0 .5.5 0 1 1 1 0zM15 15.5a.5 1.5 30 1 1-.866-.5.5 1.5 30 1 1 .866.5z" fill="#000" stroke="#000"/></g>`,
|
||||
wp: `<path d="M22.5 9c-2.21 0-4 1.79-4 4 0 .89.29 1.71.78 2.38-1.95 1.12-3.28 3.21-3.28 5.62 0 2.03.94 3.84 2.41 5.03-3 1.06-7.41 5.55-7.41 13.47h23c0-7.92-4.41-12.41-7.41-13.47 1.47-1.19 2.41-3 2.41-5.03 0-2.41-1.33-4.5-3.28-5.62.49-.67.78-1.49.78-2.38 0-2.21-1.79-4-4-4z" fill="#fff" stroke="#000" stroke-width="1.5" stroke-linecap="round"/>`,
|
||||
bk: `<g fill="none" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22.5 11.63V6" stroke-linejoin="miter"/><path d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5" fill="#000" stroke-linecap="butt" stroke-linejoin="miter"/><path d="M11.5 37c5.5 3.5 15.5 3.5 21 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-3.5-7.5-13-10.5-16-4-3 6 5 10 5 10V37z" fill="#000"/><path d="M20 8h5" stroke-linejoin="miter"/><path d="M32 29.5s8.5-4 6.03-9.65C34.15 14 25 18 22.5 24.5l.01 2.1-.01-2.1C20 18 9.906 14 6.997 19.85c-2.497 5.65 4.853 9 4.853 9M11.5 30c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0m-21 3.5c5.5-3 15.5-3 21 0" stroke="#fff"/></g>`,
|
||||
bq: `<g fill="#000" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><g stroke="none"><circle cx="6" cy="12" r="2.75"/><circle cx="14" cy="9" r="2.75"/><circle cx="22.5" cy="8" r="2.75"/><circle cx="31" cy="9" r="2.75"/><circle cx="39" cy="12" r="2.75"/></g><path d="M9 26c8.5-1.5 21-1.5 27 0l2.5-12.5L31 25l-.3-14.1-5.2 13.6-3-14.5-3 14.5-5.2-13.6L14 25 6.5 13.5 9 26z" stroke-linecap="butt"/><path d="M9 26c0 2 1.5 2 2.5 4 1 1.5 1 1 .5 3.5-1.5 1-1.5 2.5-1.5 2.5-1.5 1.5.5 2.5.5 2.5 6.5 1 16.5 1 23 0 0 0 1.5-1 0-2.5 0 0 .5-1.5-1-2.5-.5-2.5-.5-2 .5-3.5 1-2 2.5-2 2.5-4-8.5-1.5-18.5-1.5-27 0z" stroke-linecap="butt"/><path d="M11 38.5a35 35 1 0 0 23 0" fill="none" stroke-linecap="butt"/><path d="M11 29a35 35 1 0 1 23 0M12.5 31.5h20M11.5 34.5a35 35 1 0 0 22 0M10.5 37.5a35 35 1 0 0 24 0" fill="none" stroke="#fff"/></g>`,
|
||||
br: `<g fill="#000" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 39h27v-3H9v3zM12.5 32l1.5-2.5h17l1.5 2.5h-20zM12 36v-4h21v4H12z" stroke-linecap="butt"/><path d="M14 29.5v-13h17v13H14z" stroke-linecap="butt" stroke-linejoin="miter"/><path d="M14 16.5L11 14h23l-3 2.5H14zM11 14V9h4v2h5V9h5v2h5V9h4v5H11z" stroke-linecap="butt"/><path d="M12 35.5h21M13 31.5h19M14 29.5h17M14 16.5h17M11 14h23" fill="none" stroke="#fff" stroke-width="1" stroke-linejoin="miter"/></g>`,
|
||||
bb: `<g fill="none" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><g fill="#000" stroke-linecap="butt"><path d="M9 36c3.39-.97 10.11.43 13.5-2 3.39 2.43 10.11 1.03 13.5 2 0 0 1.65.54 3 2-.68.97-1.65.99-3 .5-3.39-.97-10.11.46-13.5-1-3.39 1.46-10.11.03-13.5 1-1.354.49-2.323.47-3-.5 1.354-1.94 3-2 3-2z"/><path d="M15 32c2.5 2.5 12.5 2.5 15 0 .5-1.5 0-2 0-2 0-2.5-2.5-4-2.5-4 5.5-1.5 6-11.5-5-15.5-11 4-10.5 14-5 15.5 0 0-2.5 1.5-2.5 4 0 0-.5.5 0 2z"/><path d="M25 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 1 1 5 0z"/></g><path d="M17.5 26h10M15 30h15m-7.5-14.5v5M20 18h5" stroke="#fff" stroke-linejoin="miter"/></g>`,
|
||||
bn: `<g fill="none" fill-rule="evenodd" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10c10.5 1 16.5 8 16 29H15c0-9 10-6.5 8-21" fill="#000"/><path d="M24 18c.38 2.91-5.55 7.37-8 9-3 2-2.82 4.34-5 4-1.042-.94 1.41-3.04 0-3-1 0 .19 1.23-1 2-1 0-4.003 1-4-4 0-2 6-12 6-12s1.89-1.9 2-3.5c-.73-.994-.5-2-.5-3 1-1 3 2.5 3 2.5h2s.78-1.992 2.5-3c1 0 1 3 1 3" fill="#000"/><path d="M9.5 25.5a.5.5 0 1 1-1 0 .5.5 0 1 1 1 0zM15 15.5a.5 1.5 30 1 1-.866-.5.5 1.5 30 1 1 .866.5z" fill="#fff" stroke="#fff"/><path d="M24.55 10.4l-.45 1.45.5.15c3.15 1 5.65 2.49 7.9 6.75S35.75 29.06 35.25 39l-.05.5h2.25l.05-.5c.5-10.06-.88-16.85-3.25-21.34-2.37-4.49-5.79-6.64-9.19-7.16l-.51-.1z" fill="#fff" stroke="none"/></g>`,
|
||||
bp: `<path d="M22.5 9c-2.21 0-4 1.79-4 4 0 .89.29 1.71.78 2.38-1.95 1.12-3.28 3.21-3.28 5.62 0 2.03.94 3.84 2.41 5.03-3 1.06-7.41 5.55-7.41 13.47h23c0-7.92-4.41-12.41-7.41-13.47 1.47-1.19 2.41-3 2.41-5.03 0-2.41-1.33-4.5-3.28-5.62.49-.67.78-1.49.78-2.38 0-2.21-1.79-4-4-4z" fill="#000" stroke="#000" stroke-width="1.5" stroke-linecap="round"/>`,
|
||||
};
|
||||
|
||||
const CBURNETT_DATA: PieceSetData = {
|
||||
license: "Public domain (Colin M.L. Burnett)",
|
||||
vbX: 0,
|
||||
vbY: 0,
|
||||
vbW: 45,
|
||||
vbH: 45,
|
||||
pieces: CBURNETT,
|
||||
};
|
||||
|
||||
/**
|
||||
* The "letter" set isn't fetched from Lichess; it's just SVG <text> glyphs
|
||||
* with the standard chess piece initials (K Q R B N P), filled white or
|
||||
* black. Total bundle cost: ~600 bytes for the entire set.
|
||||
*/
|
||||
const LETTER_GLYPHS: Record<PieceKey, string> = {
|
||||
wk: "K",
|
||||
wq: "Q",
|
||||
wr: "R",
|
||||
wb: "B",
|
||||
wn: "N",
|
||||
wp: "P",
|
||||
bk: "K",
|
||||
bq: "Q",
|
||||
br: "R",
|
||||
bb: "B",
|
||||
bn: "N",
|
||||
bp: "P",
|
||||
};
|
||||
|
||||
function buildLetterPiece(color: "w" | "b", letter: string): string {
|
||||
const fill = color === "w" ? "#fafafa" : "#1a1a1a";
|
||||
const stroke = color === "w" ? "#1a1a1a" : "#fafafa";
|
||||
return (
|
||||
`<text x="22.5" y="32" text-anchor="middle" font-size="28" ` +
|
||||
`font-family="Georgia, 'Times New Roman', serif" font-weight="700" ` +
|
||||
`fill="${fill}" stroke="${stroke}" stroke-width="0.6">${letter}</text>`
|
||||
);
|
||||
}
|
||||
|
||||
const LETTER_DATA: PieceSetData = {
|
||||
license: "Synthesized in this plugin",
|
||||
vbX: 0,
|
||||
vbY: 0,
|
||||
vbW: 45,
|
||||
vbH: 45,
|
||||
pieces: {
|
||||
wk: buildLetterPiece("w", LETTER_GLYPHS.wk),
|
||||
wq: buildLetterPiece("w", LETTER_GLYPHS.wq),
|
||||
wr: buildLetterPiece("w", LETTER_GLYPHS.wr),
|
||||
wb: buildLetterPiece("w", LETTER_GLYPHS.wb),
|
||||
wn: buildLetterPiece("w", LETTER_GLYPHS.wn),
|
||||
wp: buildLetterPiece("w", LETTER_GLYPHS.wp),
|
||||
bk: buildLetterPiece("b", LETTER_GLYPHS.bk),
|
||||
bq: buildLetterPiece("b", LETTER_GLYPHS.bq),
|
||||
br: buildLetterPiece("b", LETTER_GLYPHS.br),
|
||||
bb: buildLetterPiece("b", LETTER_GLYPHS.bb),
|
||||
bn: buildLetterPiece("b", LETTER_GLYPHS.bn),
|
||||
bp: buildLetterPiece("b", LETTER_GLYPHS.bp),
|
||||
},
|
||||
};
|
||||
|
||||
/** Lookup table from PieceSet identifier to its raw inline-SVG data. */
|
||||
const ALL_SET_DATA: Partial<Record<PieceSet, PieceSetData>> = {
|
||||
cburnett: CBURNETT_DATA,
|
||||
letter: LETTER_DATA,
|
||||
...PIECE_SETS,
|
||||
};
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/**
|
||||
* Cache of parsed (set, piece) -> <g> nodes. We parse each SVG fragment once
|
||||
* via DOMParser and then clone the result on every render to avoid both the
|
||||
* security risk of innerHTML and the parsing cost on every frame.
|
||||
*/
|
||||
const PARSED: Partial<Record<string, SVGGElement>> = {};
|
||||
|
||||
function cacheKey(set: PieceSet, key: PieceKey): string {
|
||||
return `${set}:${key}`;
|
||||
}
|
||||
|
||||
function parseSvgFragment(set: PieceSet, key: PieceKey): SVGGElement | null {
|
||||
const data = ALL_SET_DATA[set];
|
||||
if (!data) return null;
|
||||
const inner = data.pieces[key];
|
||||
if (!inner) return null;
|
||||
|
||||
const cached = PARSED[cacheKey(set, key)];
|
||||
if (cached) return cached;
|
||||
|
||||
// Wrap in a real <svg> doc because DOMParser refuses bare fragments.
|
||||
const wrapper = `<svg xmlns="${SVG_NS}">${inner}</svg>`;
|
||||
const doc = new DOMParser().parseFromString(wrapper, "image/svg+xml");
|
||||
const svgEl = doc.documentElement;
|
||||
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
while (svgEl.firstChild) {
|
||||
g.appendChild(svgEl.firstChild);
|
||||
}
|
||||
PARSED[cacheKey(set, key)] = g;
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fresh `<g>` element containing the requested piece's SVG artwork
|
||||
* already wrapped in a transform that places it at (0, 0) inside a 45x45
|
||||
* viewport (the renderer then translates to the destination square).
|
||||
*
|
||||
* Different sets use different source viewBoxes — we apply a uniform scale
|
||||
* (and translate, for sets with a non-zero origin like pixel's `0 -0.5`) so
|
||||
* everything renders into the same 45x45 cell without distortion.
|
||||
*
|
||||
* Returns `null` for non-SVG sets (e.g. unicode), in which case the renderer
|
||||
* falls back to the Unicode-glyph code path.
|
||||
*/
|
||||
export function createPieceNode(set: PieceSet, key: PieceKey): SVGGElement | null {
|
||||
const data = ALL_SET_DATA[set];
|
||||
if (!data) return null;
|
||||
const inner = parseSvgFragment(set, key);
|
||||
if (!inner) return null;
|
||||
|
||||
const cloned = inner.cloneNode(true) as SVGGElement;
|
||||
uniquifyIds(cloned);
|
||||
|
||||
const scale = 45 / Math.max(data.vbW, data.vbH);
|
||||
const tx = -data.vbX * scale;
|
||||
const ty = -data.vbY * scale;
|
||||
|
||||
if (scale === 1 && tx === 0 && ty === 0) {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const wrapper = document.createElementNS(SVG_NS, "g");
|
||||
wrapper.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
if (set === "pixel") {
|
||||
// Preserve pixel-art crispness — disable smoothing on the wrapper.
|
||||
wrapper.setAttribute("shape-rendering", "crispEdges");
|
||||
wrapper.setAttribute("image-rendering", "pixelated");
|
||||
}
|
||||
wrapper.appendChild(cloned);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
const URL_REF_RE = /url\(#([^)]+)\)/g;
|
||||
|
||||
/**
|
||||
* Several Lichess piece sets define internal `<linearGradient id="a">` and
|
||||
* reference them via `fill="url(#a)"`. When we drop multiple pieces (each a
|
||||
* cloned subtree) into a single board <svg>, those duplicate IDs collide and
|
||||
* every reference resolves to whichever fragment came first.
|
||||
*
|
||||
* To keep gradients/clipPaths working per-piece we walk each cloned piece
|
||||
* and rewrite all `id` attributes to a globally-unique value, then patch
|
||||
* any matching `url(#…)` paint refs and `href`/`xlink:href` link refs to
|
||||
* point at the new IDs. This is a no-op for sets without internal IDs
|
||||
* (cburnett, staunty, pixel, letter, unicode), so we only pay the walk
|
||||
* cost for merida and caliente.
|
||||
*/
|
||||
function uniquifyIds(root: SVGGElement): void {
|
||||
const elementsWithId = root.querySelectorAll("[id]");
|
||||
if (elementsWithId.length === 0) return;
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
for (let i = 0; i < elementsWithId.length; i++) {
|
||||
const el = elementsWithId.item(i);
|
||||
if (!el) continue;
|
||||
const oldId = el.getAttribute("id");
|
||||
if (!oldId) continue;
|
||||
const newId = `cs-${idCounter++}`;
|
||||
idMap.set(oldId, newId);
|
||||
el.setAttribute("id", newId);
|
||||
}
|
||||
if (idMap.size === 0) return;
|
||||
|
||||
rewriteRefs(root, idMap);
|
||||
}
|
||||
|
||||
function rewriteRefs(node: Element, idMap: Map<string, string>): void {
|
||||
for (let i = 0; i < node.attributes.length; i++) {
|
||||
const attr = node.attributes.item(i);
|
||||
if (!attr) continue;
|
||||
const value = attr.value;
|
||||
|
||||
if (value.includes("url(#")) {
|
||||
const next = value.replace(URL_REF_RE, (match, oldId: string) => {
|
||||
const newId = idMap.get(oldId);
|
||||
return newId ? `url(#${newId})` : match;
|
||||
});
|
||||
if (next !== value) attr.value = next;
|
||||
}
|
||||
|
||||
if ((attr.localName === "href") && value.startsWith("#")) {
|
||||
const newId = idMap.get(value.slice(1));
|
||||
if (newId) attr.value = `#${newId}`;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children.item(i);
|
||||
if (child) rewriteRefs(child, idMap);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unicode glyph for a piece (used by the "unicode" piece set). */
|
||||
export function getPieceGlyph(key: PieceKey): string {
|
||||
return UNICODE[key];
|
||||
}
|
||||
|
||||
/** Returns license text for a given set, for credits/attribution UI. */
|
||||
export function getPieceSetLicense(set: PieceSet): string | null {
|
||||
if (set === "unicode") return "System font";
|
||||
return ALL_SET_DATA[set]?.license ?? null;
|
||||
}
|
||||
874
src/chess/wcc-data.generated.ts
Normal file
874
src/chess/wcc-data.generated.ts
Normal file
|
|
@ -0,0 +1,874 @@
|
|||
// AUTO-GENERATED by scripts/fetch-wcc-games.mjs — do not edit by hand.
|
||||
// Re-run that script to refresh.
|
||||
|
||||
export interface WccGameData {
|
||||
id: string;
|
||||
matchSlug: string;
|
||||
matchLabel: string;
|
||||
matchYear: number;
|
||||
gameNumber: number;
|
||||
white: string;
|
||||
black: string;
|
||||
result: string;
|
||||
pgn: string;
|
||||
}
|
||||
|
||||
export interface WccMatchData {
|
||||
matchSlug: string;
|
||||
matchLabel: string;
|
||||
matchYear: number;
|
||||
}
|
||||
|
||||
export const WCC_MATCHES: WccMatchData[] = [
|
||||
{
|
||||
matchSlug: "1886-steinitz-zukertort",
|
||||
matchLabel: "1886 — Steinitz vs Zukertort",
|
||||
matchYear: 1886,
|
||||
},
|
||||
{
|
||||
matchSlug: "1894-lasker-steinitz",
|
||||
matchLabel: "1894 — Lasker vs Steinitz",
|
||||
matchYear: 1894,
|
||||
},
|
||||
{
|
||||
matchSlug: "1921-capablanca-lasker",
|
||||
matchLabel: "1921 — Capablanca vs Lasker",
|
||||
matchYear: 1921,
|
||||
},
|
||||
{
|
||||
matchSlug: "1927-alekhine-capablanca",
|
||||
matchLabel: "1927 — Alekhine vs Capablanca",
|
||||
matchYear: 1927,
|
||||
},
|
||||
{
|
||||
matchSlug: "1948-botvinnik-tournament",
|
||||
matchLabel: "1948 — Botvinnik 1st (tournament)",
|
||||
matchYear: 1948,
|
||||
},
|
||||
{
|
||||
matchSlug: "1951-botvinnik-bronstein",
|
||||
matchLabel: "1951 — Botvinnik vs Bronstein",
|
||||
matchYear: 1951,
|
||||
},
|
||||
{
|
||||
matchSlug: "1960-tal-botvinnik",
|
||||
matchLabel: "1960 — Tal vs Botvinnik",
|
||||
matchYear: 1960,
|
||||
},
|
||||
{
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
},
|
||||
{
|
||||
matchSlug: "1985-kasparov-karpov",
|
||||
matchLabel: "1985 — Kasparov vs Karpov",
|
||||
matchYear: 1985,
|
||||
},
|
||||
{
|
||||
matchSlug: "1990-kasparov-karpov",
|
||||
matchLabel: "1990 — Kasparov vs Karpov",
|
||||
matchYear: 1990,
|
||||
},
|
||||
{
|
||||
matchSlug: "2000-kramnik-kasparov",
|
||||
matchLabel: "2000 — Kramnik vs Kasparov",
|
||||
matchYear: 2000,
|
||||
},
|
||||
{
|
||||
matchSlug: "2008-anand-kramnik",
|
||||
matchLabel: "2008 — Anand vs Kramnik",
|
||||
matchYear: 2008,
|
||||
},
|
||||
{
|
||||
matchSlug: "2010-anand-topalov",
|
||||
matchLabel: "2010 — Anand vs Topalov",
|
||||
matchYear: 2010,
|
||||
},
|
||||
{
|
||||
matchSlug: "2013-carlsen-anand",
|
||||
matchLabel: "2013 — Carlsen vs Anand",
|
||||
matchYear: 2013,
|
||||
},
|
||||
{
|
||||
matchSlug: "2014-carlsen-anand",
|
||||
matchLabel: "2014 — Carlsen vs Anand",
|
||||
matchYear: 2014,
|
||||
},
|
||||
{
|
||||
matchSlug: "2016-carlsen-karjakin",
|
||||
matchLabel: "2016 — Carlsen vs Karjakin",
|
||||
matchYear: 2016,
|
||||
},
|
||||
{
|
||||
matchSlug: "2018-carlsen-caruana",
|
||||
matchLabel: "2018 — Carlsen vs Caruana",
|
||||
matchYear: 2018,
|
||||
},
|
||||
{
|
||||
matchSlug: "2021-carlsen-nepomniachtchi",
|
||||
matchLabel: "2021 — Carlsen vs Nepomniachtchi",
|
||||
matchYear: 2021,
|
||||
},
|
||||
{
|
||||
matchSlug: "2023-ding-nepomniachtchi",
|
||||
matchLabel: "2023 — Ding vs Nepomniachtchi",
|
||||
matchYear: 2023,
|
||||
},
|
||||
{
|
||||
matchSlug: "2024-gukesh-ding",
|
||||
matchLabel: "2024 — Gukesh vs Ding",
|
||||
matchYear: 2024,
|
||||
},
|
||||
];
|
||||
|
||||
export const WCC_GAMES: WccGameData[] = [
|
||||
{
|
||||
id: "1886-steinitz-zukertort-01",
|
||||
matchSlug: "1886-steinitz-zukertort",
|
||||
matchLabel: "1886 — Steinitz vs Zukertort",
|
||||
matchYear: 1886,
|
||||
gameNumber: 1,
|
||||
white: "Zukertort, Johannes Hermann",
|
||||
black: "Steinitz, William",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 1st\"]\n[Site \"USA\"]\n[Date \"1886.??.??\"]\n[Round \"1\"]\n[White \"Zukertort, Johannes Hermann\"]\n[Black \"Steinitz, William\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D11\"]\n\n1.d4 d5 2.c4 c6 3.e3 Bf5 4.Nc3 e6 5.Nf3 Nd7 6.a3 Bd6 7.c5 Bc7 8.b4 e5 9.Be2 Ngf6\n10.Bb2 e4 11.Nd2 h5 12.h3 Nf8 13.a4 Ng6 14.b5 Nh4 15.g3 Ng2+ 16.Kf1 Nxe3+\n17.fxe3 Bxg3 18.Kg2 Bc7 19.Qg1 Rh6 20.Kf1 Rg6 21.Qf2 Qd7 22.bxc6 bxc6 23.Rg1 Bxh3+\n24.Ke1 Ng4 25.Bxg4 Bxg4 26.Ne2 Qe7 27.Nf4 Rh6 28.Bc3 g5 29.Ne2 Rf6 30.Qg2 Rf3\n31.Nf1 Rb8 32.Kd2 f5 33.a5 f4 34.Rh1 Qf7 35.Re1 fxe3+ 36.Nxe3 Rf2 37.Qxf2 Qxf2\n38.Nxg4 Bf4+ 39.Kc2 hxg4 40.Bd2 e3 41.Bc1 Qg2 42.Kc3 Kd7 43.Rh7+ Ke6 44.Rh6+ Kf5\n45.Bxe3 Bxe3 46.Rf1+ Bf4 0-1",
|
||||
},
|
||||
{
|
||||
id: "1886-steinitz-zukertort-09",
|
||||
matchSlug: "1886-steinitz-zukertort",
|
||||
matchLabel: "1886 — Steinitz vs Zukertort",
|
||||
matchYear: 1886,
|
||||
gameNumber: 9,
|
||||
white: "Zukertort, Johannes Hermann",
|
||||
black: "Steinitz, William",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 1st\"]\n[Site \"USA\"]\n[Date \"1886.??.??\"]\n[Round \"9\"]\n[White \"Zukertort, Johannes Hermann\"]\n[Black \"Steinitz, William\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D26\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Nf6 4.Nf3 dxc4 5.e3 c5 6.Bxc4 cxd4 7.exd4 Be7 8.O-O O-O\n9.Qe2 Nbd7 10.Bb3 Nb6 11.Bf4 Nbd5 12.Bg3 Qa5 13.Rac1 Bd7 14.Ne5 Rfd8 15.Qf3 Be8\n16.Rfe1 Rac8 17.Bh4 Nxc3 18.bxc3 Qc7 19.Qd3 Nd5 20.Bxe7 Qxe7 21.Bxd5 Rxd5\n22.c4 Rdd8 23.Re3 Qd6 24.Rd1 f6 25.Rh3 h6 26.Ng4 Qf4 27.Ne3 Ba4 28.Rf3 Qd6\n29.Rd2 Bc6 30.Rg3 f5 31.Rg6 Be4 32.Qb3 Kh7 33.c5 Rxc5 34.Rxe6 Rc1+ 35.Nd1 Qf4\n36.Qb2 Rb1 37.Qc3 Rc8 38.Rxe4 Qxe4 0-1",
|
||||
},
|
||||
{
|
||||
id: "1886-steinitz-zukertort-20",
|
||||
matchSlug: "1886-steinitz-zukertort",
|
||||
matchLabel: "1886 — Steinitz vs Zukertort",
|
||||
matchYear: 1886,
|
||||
gameNumber: 20,
|
||||
white: "Steinitz, William",
|
||||
black: "Zukertort, Johannes Hermann",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 1st\"]\n[Site \"USA\"]\n[Date \"1886.??.??\"]\n[Round \"20\"]\n[White \"Steinitz, William\"]\n[Black \"Zukertort, Johannes Hermann\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C25\"]\n\n1.e4 e5 2.Nc3 Nc6 3.f4 exf4 4.d4 d5 5.exd5 Qh4+ 6.Ke2 Qe7+ 7.Kf2 Qh4+ 8.g3 fxg3+\n9.Kg2 Nxd4 10.hxg3 Qg4 11.Qe1+ Be7 12.Bd3 Nf5 13.Nf3 Bd7 14.Bf4 f6 15.Ne4 Ngh6\n16.Bxh6 Nxh6 17.Rxh6 gxh6 18.Nxf6+ Kf7 19.Nxg4 1-0",
|
||||
},
|
||||
{
|
||||
id: "1894-lasker-steinitz-05",
|
||||
matchSlug: "1894-lasker-steinitz",
|
||||
matchLabel: "1894 — Lasker vs Steinitz",
|
||||
matchYear: 1894,
|
||||
gameNumber: 5,
|
||||
white: "Lasker, Emanuel",
|
||||
black: "Steinitz, William",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"World Championship 5th\"]\n[Site \"USA/CAN\"]\n[Date \"1894.??.??\"]\n[Round \"5\"]\n[White \"Lasker, Emanuel\"]\n[Black \"Steinitz, William\"]\n[Result \"1/2-1/2\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C62\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 d6 4.d4 Bd7 5.Nc3 Nge7 6.Bc4 exd4 7.Nxd4 Nxd4 8.Qxd4 Nc6\n9.Qe3 Be6 10.Nd5 Be7 11.Bd2 O-O 12.O-O Ne5 13.Bb3 Bxd5 14.Bxd5 c6 15.Bb3 Nd7\n16.Rad1 a5 17.c3 a4 18.Bc2 Re8 19.Qh3 Nf8 20.Be3 Qa5 21.a3 Qb5 22.Bc1 Rad8\n23.Rd4 d5 24.exd5 Bc5 25.Rf4 Ng6 26.c4 Qa6 27.Bxg6 fxg6 28.Rh4 h5 29.Bg5 Rd6\n30.dxc6 Qxc6 31.Qf3 Qxf3 32.gxf3 Re2 33.Bc1 Rxf2 34.Rxf2 Rd1+ 35.Kg2 Bxf2\n36.Kxf2 Rxc1 37.Kg3 b6 38.Rd4 Rc2 39.Rd8+ Kh7 40.Rb8 Rxb2 41.Ra8 g5 42.Rxa4 h4+\n43.Kh3 Rf2 44.Rb4 Rxf3+ 45.Kg4 Rxa3 46.Rxb6 Ra2 47.Kxg5 Rxh2 48.Rb3 Rh1 49.Rc3 h3\n50.Kg4 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "1894-lasker-steinitz-07",
|
||||
matchSlug: "1894-lasker-steinitz",
|
||||
matchLabel: "1894 — Lasker vs Steinitz",
|
||||
matchYear: 1894,
|
||||
gameNumber: 7,
|
||||
white: "Lasker, Emanuel",
|
||||
black: "Steinitz, William",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 5th\"]\n[Site \"USA/CAN\"]\n[Date \"1894.??.??\"]\n[Round \"7\"]\n[White \"Lasker, Emanuel\"]\n[Black \"Steinitz, William\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C62\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 d6 4.d4 Bd7 5.Nc3 Nge7 6.Be3 Ng6 7.Qd2 Be7 8.O-O-O a6\n9.Be2 exd4 10.Nxd4 Nxd4 11.Qxd4 Bf6 12.Qd2 Bc6 13.Nd5 O-O 14.g4 Re8 15.g5 Bxd5\n16.Qxd5 Re5 17.Qd2 Bxg5 18.f4 Rxe4 19.fxg5 Qe7 20.Rdf1 Rxe3 21.Bc4 Nh8 22.h4 c6\n23.g6 d5 24.gxh7+ Kxh7 25.Bd3+ Kg8 26.h5 Re8 27.h6 g6 28.h7+ Kg7 29.Kb1 Qe5\n30.a3 c5 31.Qf2 c4 32.Qh4 f6 33.Bf5 Kf7 34.Rhg1 gxf5 35.Qh5+ Ke7 36.Rg8 Kd6\n37.Rxf5 Qe6 38.Rxe8 Qxe8 39.Rxf6+ Kc5 40.Qh6 Re7 41.Qh2 Qd7 42.Qg1+ d4 43.Qg5+ Qd5\n44.Rf5 Qxf5 45.Qxf5+ Kd6 46.Qf6+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1894-lasker-steinitz-17",
|
||||
matchSlug: "1894-lasker-steinitz",
|
||||
matchLabel: "1894 — Lasker vs Steinitz",
|
||||
matchYear: 1894,
|
||||
gameNumber: 17,
|
||||
white: "Lasker, Emanuel",
|
||||
black: "Steinitz, William",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 5th\"]\n[Site \"USA/CAN\"]\n[Date \"1894.??.??\"]\n[Round \"17\"]\n[White \"Lasker, Emanuel\"]\n[Black \"Steinitz, William\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C50\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bc4 Bc5 4.d3 Nf6 5.Nc3 d6 6.Be3 Bb6 7.Qd2 Na5 8.Bb5+ c6\n9.Ba4 Bxe3 10.fxe3 b5 11.Bb3 Qb6 12.O-O Ng4 13.Rae1 f6 14.h3 Nh6 15.Ne2 Nxb3\n16.axb3 O-O 17.Ng3 a5 18.d4 Nf7 19.Qf2 Ra7 20.Rd1 a4 21.b4 Qc7 22.Ne1 c5\n23.Qd2 Be6 24.d5 Bd7 25.Ra1 cxb4 26.Qxb4 Rc8 27.Qd2 Qc4 28.Rf2 Ng5 29.Qd3 Rac7\n30.h4 Nf7 31.Qxc4 Rxc4 32.Rd2 g6 33.Kf2 Nd8 34.b3 R4c7 35.Rdd1 Nb7 36.Rdb1 Kf7\n37.Ke2 Ra8 38.Kd2 Na5 39.Kd3 h5 40.Ra2 Raa7 41.b4 Nc4 42.Nf3 Ra8 43.Nd2 Nb6\n44.Rf1 Rac8 45.Nb1 Ke7 46.c3 Nc4 47.Raf2 Na3 48.Ne2 Nxb1 49.Rxb1 Bg4 50.Rc1 Rc4\n51.Rc2 f5 0-1",
|
||||
},
|
||||
{
|
||||
id: "1894-lasker-steinitz-19",
|
||||
matchSlug: "1894-lasker-steinitz",
|
||||
matchLabel: "1894 — Lasker vs Steinitz",
|
||||
matchYear: 1894,
|
||||
gameNumber: 19,
|
||||
white: "Lasker, Emanuel",
|
||||
black: "Steinitz, William",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 5th\"]\n[Site \"USA/CAN\"]\n[Date \"1894.??.??\"]\n[Round \"19\"]\n[White \"Lasker, Emanuel\"]\n[Black \"Steinitz, William\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D40\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Nf6 4.Nf3 Be7 5.e3 O-O 6.Bd3 c5 7.dxc5 dxc4 8.Bxc4 Qxd1+\n9.Kxd1 Nc6 10.a3 Bxc5 11.b4 Rd8+ 12.Ke2 Bf8 13.Bb2 Bd7 14.Rhd1 Rac8 15.Bb3 Ne7\n16.Nd4 Ng6 17.Rd2 e5 18.Nf3 Bg4 19.Rxd8 Rxd8 20.h3 Bxf3+ 21.gxf3 Be7 22.Rc1 Kf8\n23.Na4 b6 24.Nc3 Bd6 25.Rd1 Ne8 26.Nb5 Rd7 27.Bc2 Ke7 28.Bf5 a6 29.Bxd7 Kxd7\n30.Nc3 f5 31.b5 axb5 32.Nxb5 Ke6 33.Bc3 Ne7 34.Nxd6 Nxd6 35.Bb4 Nd5 36.Rc1 Nf7\n37.Bd2 Nd6 38.Kd3 Kd7 39.e4 Nf6 40.Be3 fxe4+ 41.fxe4 b5 42.f3 Nc4 43.Rc3 Ne8\n44.Bc1 Ncd6 45.Rc5 Nc7 46.Rxe5 Ne6 47.Rh5 h6 48.Re5 g5 49.h4 gxh4 50.Rh5 Kc6\n51.Rxh6 Nc5+ 52.Kc2 1-0",
|
||||
},
|
||||
{
|
||||
id: "1921-capablanca-lasker-05",
|
||||
matchSlug: "1921-capablanca-lasker",
|
||||
matchLabel: "1921 — Capablanca vs Lasker",
|
||||
matchYear: 1921,
|
||||
gameNumber: 5,
|
||||
white: "Capablanca, Jose Raul",
|
||||
black: "Lasker, Emanuel",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 12th\"]\n[Site \"Havana\"]\n[Date \"1921.??.??\"]\n[Round \"5\"]\n[White \"Capablanca, Jose Raul\"]\n[Black \"Lasker, Emanuel\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D63\"]\n\n1.d4 d5 2.Nf3 Nf6 3.c4 e6 4.Bg5 Nbd7 5.e3 Be7 6.Nc3 O-O 7.Rc1 b6 8.cxd5 exd5\n9.Qa4 c5 10.Qc6 Rb8 11.Nxd5 Bb7 12.Nxe7+ Qxe7 13.Qa4 Rbc8 14.Qa3 Qe6 15.Bxf6 Qxf6\n16.Ba6 Bxf3 17.Bxc8 Rxc8 18.gxf3 Qxf3 19.Rg1 Re8 20.Qd3 g6 21.Kf1 Re4 22.Qd1 Qh3+\n23.Rg2 Nf6 24.Kg1 cxd4 25.Rc4 dxe3 26.Rxe4 Nxe4 27.Qd8+ Kg7 28.Qd4+ Nf6 29.fxe3 Qe6\n30.Rf2 g5 31.h4 gxh4 32.Qxh4 Ng4 33.Qg5+ Kf8 34.Rf5 h5 35.Qd8+ Kg7 36.Qg5+ Kf8\n37.Qd8+ Kg7 38.Qg5+ Kf8 39.b3 Qd6 40.Qf4 Qd1+ 41.Qf1 Qd2 42.Rxh5 Nxe3 43.Qf3 Qd4\n44.Qa8+ Ke7 45.Qb7+ Kf8 46.Qb8+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1921-capablanca-lasker-10",
|
||||
matchSlug: "1921-capablanca-lasker",
|
||||
matchLabel: "1921 — Capablanca vs Lasker",
|
||||
matchYear: 1921,
|
||||
gameNumber: 10,
|
||||
white: "Lasker, Emanuel",
|
||||
black: "Capablanca, Jose Raul",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 12th\"]\n[Site \"Havana\"]\n[Date \"1921.??.??\"]\n[Round \"10\"]\n[White \"Lasker, Emanuel\"]\n[Black \"Capablanca, Jose Raul\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D61\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Nf6 4.Bg5 Be7 5.e3 O-O 6.Nf3 Nbd7 7.Qc2 c5 8.Rd1 Qa5\n9.Bd3 h6 10.Bh4 cxd4 11.exd4 dxc4 12.Bxc4 Nb6 13.Bb3 Bd7 14.O-O Rac8 15.Ne5 Bb5\n16.Rfe1 Nbd5 17.Bxd5 Nxd5 18.Bxe7 Nxe7 19.Qb3 Bc6 20.Nxc6 bxc6 21.Re5 Qb6\n22.Qc2 Rfd8 23.Ne2 Rd5 24.Rxd5 cxd5 25.Qd2 Nf5 26.b3 h5 27.h3 h4 28.Qd3 Rc6\n29.Kf1 g6 30.Qb1 Qb4 31.Kg1 a5 32.Qb2 a4 33.Qd2 Qxd2 34.Rxd2 axb3 35.axb3 Rb6\n36.Rd3 Ra6 37.g4 hxg3 38.fxg3 Ra2 39.Nc3 Rc2 40.Nd1 Ne7 41.Nc3 Rc1+ 42.Kf2 Nc6\n43.Nd1 Rb1 44.Ke2 Rxb3 45.Ke3 Rb4 46.Nc3 Ne7 47.Ne2 Nf5+ 48.Kf2 g5 49.g4 Nd6\n50.Ng1 Ne4+ 51.Kf1 Rb1+ 52.Kg2 Rb2+ 53.Kf1 Rf2+ 54.Ke1 Ra2 55.Kf1 Kg7 56.Re3 Kg6\n57.Rd3 f6 58.Re3 Kf7 59.Rd3 Ke7 60.Re3 Kd6 61.Rd3 Rf2+ 62.Ke1 Rg2 63.Kf1 Ra2\n64.Re3 e5 65.Rd3 exd4 66.Rxd4 Kc5 67.Rd1 d4 68.Rc1+ Kd5 0-1",
|
||||
},
|
||||
{
|
||||
id: "1921-capablanca-lasker-11",
|
||||
matchSlug: "1921-capablanca-lasker",
|
||||
matchLabel: "1921 — Capablanca vs Lasker",
|
||||
matchYear: 1921,
|
||||
gameNumber: 11,
|
||||
white: "Capablanca, Jose Raul",
|
||||
black: "Lasker, Emanuel",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 12th\"]\n[Site \"Havana\"]\n[Date \"1921.??.??\"]\n[Round \"11\"]\n[White \"Capablanca, Jose Raul\"]\n[Black \"Lasker, Emanuel\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D64\"]\n\n1.d4 d5 2.Nf3 e6 3.c4 Nf6 4.Bg5 Nbd7 5.e3 Be7 6.Nc3 O-O 7.Rc1 Re8 8.Qc2 c6\n9.Bd3 dxc4 10.Bxc4 Nd5 11.Bxe7 Rxe7 12.O-O Nf8 13.Rfd1 Bd7 14.e4 Nb6 15.Bf1 Rc8\n16.b4 Be8 17.Qb3 Rec7 18.a4 Ng6 19.a5 Nd7 20.e5 b6 21.Ne4 Rb8 22.Qc3 Nf4\n23.Nd6 Nd5 24.Qa3 f6 25.Nxe8 Qxe8 26.exf6 gxf6 27.b5 Rbc8 28.bxc6 Rxc6 29.Rxc6 Rxc6\n30.axb6 axb6 31.Re1 Qc8 32.Nd2 Nf8 33.Ne4 Qd8 34.h4 Rc7 35.Qb3 Rg7 36.g3 Ra7\n37.Bc4 Ra5 38.Nc3 Nxc3 39.Qxc3 Kf7 40.Qe3 Qd6 41.Qe4 Ra4 42.Qb7+ Kg6 43.Qc8 Qb4\n44.Rc1 Qe7 45.Bd3+ Kh6 46.Rc7 Ra1+ 47.Kg2 Qd6 48.Qxf8+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1921-capablanca-lasker-14",
|
||||
matchSlug: "1921-capablanca-lasker",
|
||||
matchLabel: "1921 — Capablanca vs Lasker",
|
||||
matchYear: 1921,
|
||||
gameNumber: 14,
|
||||
white: "Lasker, Emanuel",
|
||||
black: "Capablanca, Jose Raul",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 12th\"]\n[Site \"Havana\"]\n[Date \"1921.??.??\"]\n[Round \"14\"]\n[White \"Lasker, Emanuel\"]\n[Black \"Capablanca, Jose Raul\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C66\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O d6 5.d4 Bd7 6.Nc3 Be7 7.Bxc6 Bxc6 8.Qd3 exd4\n9.Nxd4 Bd7 10.Bg5 O-O 11.Rae1 h6 12.Bh4 Nh7 13.Bxe7 Qxe7 14.Nd5 Qd8 15.c4 Re8\n16.f4 c6 17.Nc3 Qb6 18.b3 Rad8 19.Kh1 Nf6 20.h3 Bc8 21.Rd1 Re7 22.Rfe1 Rde8\n23.Re2 Qa5 24.Rf1 Qh5 25.Kg1 a6 26.Rff2 Qg6 27.Rf3 Qh5 28.f5 Qh4 29.Kh2 Ng4+\n30.Kh1 Ne5 31.Qd2 Nxf3 32.Nxf3 Qf6 33.a4 g6 34.fxg6 fxg6 35.Re3 Bf5 36.Qd3 g5\n37.Nd2 Bg6 38.b4 Qe6 39.b5 axb5 40.axb5 Ra8 41.Qb1 Qe5 42.Qe1 Kh7 43.bxc6 bxc6\n44.Qg3 Qxg3 45.Rxg3 Ra3 46.Kh2 Rb7 47.c5 dxc5 48.Nc4 Ra1 49.Ne5 Rc1 50.h4 Re7\n51.Nxc6 Re6 52.Nd8 gxh4 53.Rd3 Rf6 54.Rd7+ Kh8 55.Nd5 Rff1 56.Kh3 Bxe4 0-1",
|
||||
},
|
||||
{
|
||||
id: "1927-alekhine-capablanca-01",
|
||||
matchSlug: "1927-alekhine-capablanca",
|
||||
matchLabel: "1927 — Alekhine vs Capablanca",
|
||||
matchYear: 1927,
|
||||
gameNumber: 1,
|
||||
white: "Capablanca, Jose Raul",
|
||||
black: "Alekhine, Alexander",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 13th\"]\n[Site \"Buenos Aires\"]\n[Date \"1927.??.??\"]\n[Round \"1\"]\n[White \"Capablanca, Jose Raul\"]\n[Black \"Alekhine, Alexander\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C01\"]\n\n1.e4 e6 2.d4 d5 3.Nc3 Bb4 4.exd5 exd5 5.Bd3 Nc6 6.Ne2 Nge7 7.O-O Bf5 8.Bxf5 Nxf5\n9.Qd3 Qd7 10.Nd1 O-O 11.Ne3 Nxe3 12.Bxe3 Rfe8 13.Nf4 Bd6 14.Rfe1 Nb4 15.Qb3 Qf5\n16.Rac1 Nxc2 17.Rxc2 Qxf4 18.g3 Qf5 19.Rce2 b6 20.Qb5 h5 21.h4 Re4 22.Bd2 Rxd4\n23.Bc3 Rd3 24.Be5 Rd8 25.Bxd6 Rxd6 26.Re5 Qf3 27.Rxh5 Qxh5 28.Re8+ Kh7 29.Qxd3+ Qg6\n30.Qd1 Re6 31.Ra8 Re5 32.Rxa7 c5 33.Rd7 Qe6 34.Qd3+ g6 35.Rd8 d4 36.a4 Re1+\n37.Kg2 Qc6+ 38.f3 Re3 39.Qd1 Qe6 40.g4 Re2+ 41.Kh3 Qe3 42.Qh1 Qf4 43.h5 Rf2 0-1",
|
||||
},
|
||||
{
|
||||
id: "1927-alekhine-capablanca-11",
|
||||
matchSlug: "1927-alekhine-capablanca",
|
||||
matchLabel: "1927 — Alekhine vs Capablanca",
|
||||
matchYear: 1927,
|
||||
gameNumber: 11,
|
||||
white: "Capablanca, Jose Raul",
|
||||
black: "Alekhine, Alexander",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 13th\"]\n[Site \"Buenos Aires\"]\n[Date \"1927.??.??\"]\n[Round \"11\"]\n[White \"Capablanca, Jose Raul\"]\n[Black \"Alekhine, Alexander\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D52\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Nf6 4.Bg5 Nbd7 5.e3 c6 6.Nf3 Qa5 7.Nd2 Bb4 8.Qc2 dxc4\n9.Bxf6 Nxf6 10.Nxc4 Qc7 11.a3 Be7 12.Be2 O-O 13.O-O Bd7 14.b4 b6 15.Bf3 Rac8\n16.Rfd1 Rfd8 17.Rac1 Be8 18.g3 Nd5 19.Nb2 Qb8 20.Nd3 Bg5 21.Rb1 Qb7 22.e4 Nxc3\n23.Qxc3 Qe7 24.h4 Bh6 25.Ne5 g6 26.Ng4 Bg7 27.e5 h5 28.Ne3 c5 29.bxc5 bxc5\n30.d5 exd5 31.Nxd5 Qe6 32.Nf6+ Bxf6 33.exf6 Rxd1+ 34.Rxd1 Bc6 35.Re1 Qf5\n36.Re3 c4 37.a4 a5 38.Bg2 Bxg2 39.Kxg2 Qd5+ 40.Kh2 Qf5 41.Rf3 Qc5 42.Rf4 Kh7\n43.Rd4 Qc6 44.Qxa5 c3 45.Qa7 Kg8 46.Qe7 Qb6 47.Qd7 Qc5 48.Re4 Qxf2+ 49.Kh3 Qf1+\n50.Kh2 Qf2+ 51.Kh3 Rf8 52.Qc6 Qf1+ 53.Kh2 Qf2+ 54.Kh3 Qf1+ 55.Kh2 Kh7 56.Qc4 Qf2+\n57.Kh3 Qg1 58.Re2 Qf1+ 59.Kh2 Qxf6 60.a5 Rd8 61.a6 Qf1 62.Qe4 Rd2 63.Rxd2 cxd2\n64.a7 d1=Q 65.a8=Q Qg1+ 66.Kh3 Qdf1+ 0-1",
|
||||
},
|
||||
{
|
||||
id: "1927-alekhine-capablanca-21",
|
||||
matchSlug: "1927-alekhine-capablanca",
|
||||
matchLabel: "1927 — Alekhine vs Capablanca",
|
||||
matchYear: 1927,
|
||||
gameNumber: 21,
|
||||
white: "Capablanca, Jose Raul",
|
||||
black: "Alekhine, Alexander",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 13th\"]\n[Site \"Buenos Aires\"]\n[Date \"1927.??.??\"]\n[Round \"21\"]\n[White \"Capablanca, Jose Raul\"]\n[Black \"Alekhine, Alexander\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D63\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Nf6 4.Bg5 Nbd7 5.e3 Be7 6.Nf3 O-O 7.Rc1 a6 8.a3 h6\n9.Bh4 dxc4 10.Bxc4 b5 11.Be2 Bb7 12.O-O c5 13.dxc5 Nxc5 14.Nd4 Rc8 15.b4 Ncd7\n16.Bg3 Nb6 17.Qb3 Nfd5 18.Bf3 Rc4 19.Ne4 Qc8 20.Rxc4 Nxc4 21.Rc1 Qa8 22.Nc3 Rc8\n23.Nxd5 Bxd5 24.Bxd5 Qxd5 25.a4 Bf6 26.Nf3 Bb2 27.Re1 Rd8 28.axb5 axb5 29.h3 e5\n30.Rb1 e4 31.Nd4 Bxd4 32.Rd1 Nxe3 0-1",
|
||||
},
|
||||
{
|
||||
id: "1927-alekhine-capablanca-32",
|
||||
matchSlug: "1927-alekhine-capablanca",
|
||||
matchLabel: "1927 — Alekhine vs Capablanca",
|
||||
matchYear: 1927,
|
||||
gameNumber: 32,
|
||||
white: "Alekhine, Alexander",
|
||||
black: "Capablanca, Jose Raul",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 13th\"]\n[Site \"Buenos Aires\"]\n[Date \"1927.??.??\"]\n[Round \"32\"]\n[White \"Alekhine, Alexander\"]\n[Black \"Capablanca, Jose Raul\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D35\"]\n\n1.d4 Nf6 2.c4 e6 3.Nc3 d5 4.Bg5 Nbd7 5.e3 c6 6.cxd5 exd5 7.Bd3 Be7 8.Nge2 O-O\n9.Ng3 Ne8 10.h4 Ndf6 11.Qc2 Be6 12.Nf5 Bxf5 13.Bxf5 Nd6 14.Bd3 h6 15.Bf4 Rc8\n16.g4 Nfe4 17.g5 h5 18.Bxe4 Nxe4 19.Nxe4 dxe4 20.Qxe4 Qa5+ 21.Kf1 Qd5 22.Qxd5 cxd5\n23.Kg2 Rc2 24.Rhc1 Rfc8 25.Rxc2 Rxc2 26.Rb1 Kh7 27.Kg3 Kg6 28.f3 f6 29.gxf6 Bxf6\n30.a4 Kf5 31.a5 Re2 32.Rc1 Rxb2 33.Rc5 Ke6 34.e4 Bxd4 35.Rxd5 Bc3 36.Rxh5 a6\n37.Bc7 Be1+ 38.Kg4 Rg2+ 39.Kh3 Rf2 40.Kg4 Rg2+ 41.Kh3 Rf2 42.f4 Rf3+ 43.Kg2 Rf2+\n44.Kh3 Rf3+ 45.Kg2 Rf2+ 46.Kg1 Rc2 47.Bb6 Rc4 48.Kg2 g6 49.Re5+ Kd7 50.h5 gxh5\n51.Kf3 h4 52.Rh5 Rc3+ 53.Kg4 Rc4 54.Kf5 Bxa5 55.Rh7+ Kc6 56.Bxa5 Rc5+ 57.Ke6 Rxa5\n58.f5 Ra3 59.f6 Rf3 60.f7 b5 61.Rh5 h3 62.Rf5 Rxf5 63.exf5 1-0",
|
||||
},
|
||||
{
|
||||
id: "1927-alekhine-capablanca-34",
|
||||
matchSlug: "1927-alekhine-capablanca",
|
||||
matchLabel: "1927 — Alekhine vs Capablanca",
|
||||
matchYear: 1927,
|
||||
gameNumber: 34,
|
||||
white: "Alekhine, Alexander",
|
||||
black: "Capablanca, Jose Raul",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 13th\"]\n[Site \"Buenos Aires\"]\n[Date \"1927.??.??\"]\n[Round \"34\"]\n[White \"Alekhine, Alexander\"]\n[Black \"Capablanca, Jose Raul\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D51\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Nf6 4.Bg5 Nbd7 5.e3 c6 6.a3 Be7 7.Nf3 O-O 8.Bd3 dxc4\n9.Bxc4 Nd5 10.Bxe7 Qxe7 11.Ne4 N5f6 12.Ng3 c5 13.O-O Nb6 14.Ba2 cxd4 15.Nxd4 g6\n16.Rc1 Bd7 17.Qe2 Rac8 18.e4 e5 19.Nf3 Kg7 20.h3 h6 21.Qd2 Be6 22.Bxe6 Qxe6\n23.Qa5 Nc4 24.Qxa7 Nxb2 25.Rxc8 Rxc8 26.Qxb7 Nc4 27.Qb4 Ra8 28.Ra1 Qc6 29.a4 Nxe4\n30.Nxe5 Qd6 31.Qxc4 Qxe5 32.Re1 Nd6 33.Qc1 Qf6 34.Ne4 Nxe4 35.Rxe4 Rb8 36.Re2 Ra8\n37.Ra2 Ra5 38.Qc7 Qa6 39.Qc3+ Kh7 40.Rd2 Qb6 41.Rd7 Qb1+ 42.Kh2 Qb8+ 43.g3 Rf5\n44.Qd4 Qe8 45.Rd5 Rf3 46.h4 Qh8 47.Qb6 Qa1 48.Kg2 Rf6 49.Qd4 Qxd4 50.Rxd4 Kg7\n51.a5 Ra6 52.Rd5 Rf6 53.Rd4 Ra6 54.Ra4 Kf6 55.Kf3 Ke5 56.Ke3 h5 57.Kd3 Kd5\n58.Kc3 Kc5 59.Ra2 Kb5 60.Kb3 Kc5 61.Kc3 Kb5 62.Kd4 Rd6+ 63.Ke5 Re6+ 64.Kf4 Ka6\n65.Kg5 Re5+ 66.Kh6 Rf5 67.f4 Rc5 68.Ra3 Rc7 69.Kg7 Rd7 70.f5 gxf5 71.Kh6 f4\n72.gxf4 Rd5 73.Kg7 Rf5 74.Ra4 Kb5 75.Re4 Ka6 76.Kh6 Rxa5 77.Re5 Ra1 78.Kxh5 Rg1\n79.Rg5 Rh1 80.Rf5 Kb6 81.Rxf7 Kc6 82.Re7 1-0",
|
||||
},
|
||||
{
|
||||
id: "1948-botvinnik-tournament-09",
|
||||
matchSlug: "1948-botvinnik-tournament",
|
||||
matchLabel: "1948 — Botvinnik 1st (tournament)",
|
||||
matchYear: 1948,
|
||||
gameNumber: 9,
|
||||
white: "Reshevsky, Samuel Herman",
|
||||
black: "Botvinnik, Mikhail",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"World Championship 18th\"]\n[Site \"NLD/URS\"]\n[Date \"1948.??.??\"]\n[Round \"9\"]\n[White \"Reshevsky, Samuel Herman\"]\n[Black \"Botvinnik, Mikhail\"]\n[Result \"1/2-1/2\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"A91\"]\n\n1.d4 e6 2.c4 f5 3.g3 Nf6 4.Bg2 Be7 5.Nh3 O-O 6.O-O d6 7.Nc3 Qe8 8.e4 fxe4\n9.Nf4 c6 10.Nxe4 Nxe4 11.Bxe4 e5 12.Ng2 Nd7 13.Ne3 exd4 14.Qxd4 Ne5 15.f4 Ng4\n16.Nxg4 Bxg4 17.Re1 Bf6 18.Qd3 Qh5 19.Bd2 Rfe8 20.Rab1 Re7 21.Bb4 Rae8 22.Bxd6 Re6\n23.Re3 Rxd6 24.Qxd6 Rd8 25.Qc7 Qc5 26.Re1 Rf8 27.Qxb7 Bd4 28.Kf2 Bxe3+ 29.Rxe3 Qd4\n30.Qb3 Qd2+ 31.Kg1 Qc1+ 32.Kf2 Qd2+ 33.Kg1 Qc1+ 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "1948-botvinnik-tournament-14",
|
||||
matchSlug: "1948-botvinnik-tournament",
|
||||
matchLabel: "1948 — Botvinnik 1st (tournament)",
|
||||
matchYear: 1948,
|
||||
gameNumber: 14,
|
||||
white: "Euwe, Max",
|
||||
black: "Smyslov, Vassily",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 18th\"]\n[Site \"NLD/URS\"]\n[Date \"1948.??.??\"]\n[Round \"14\"]\n[White \"Euwe, Max\"]\n[Black \"Smyslov, Vassily\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"D99\"]\n\n1.d4 Nf6 2.c4 g6 3.Nc3 d5 4.Nf3 Bg7 5.Qb3 dxc4 6.Qxc4 O-O 7.e4 Bg4 8.Be3 Nfd7\n9.Qb3 Nb6 10.a4 a5 11.d5 Na6 12.Be2 e6 13.h3 Bxf3 14.Bxf3 exd5 15.exd5 Qh4\n16.Ne4 Rae8 17.g3 Qd8 18.d6 Nc8 19.dxc7 Qxc7 20.O-O Re6 21.Rac1 Qe5 22.Qxb7 Ne7\n23.Ng5 Rf6 24.Bf4 Rxf4 25.gxf4 Qxf4 26.Qxe7 Bf6 27.Qe3 Qxe3 28.fxe3 Bxg5\n29.Rc3 f5 30.Rd1 Nc5 31.b3 Re8 32.Rd5 Bxe3+ 33.Kg2 Na6 34.Rd7 Bf4 35.Ra7 Nb4\n36.Rxa5 Kg7 37.Rb5 Bd2 38.Rc7+ Kf6 39.Rd7 Be1 40.Rb6+ Kg5 41.h4+ Kf4 42.Rxb4+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1948-botvinnik-tournament-21",
|
||||
matchSlug: "1948-botvinnik-tournament",
|
||||
matchLabel: "1948 — Botvinnik 1st (tournament)",
|
||||
matchYear: 1948,
|
||||
gameNumber: 21,
|
||||
white: "Smyslov, Vassily",
|
||||
black: "Reshevsky, Samuel Herman",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"World Championship 18th\"]\n[Site \"NLD/URS\"]\n[Date \"1948.??.??\"]\n[Round \"21\"]\n[White \"Smyslov, Vassily\"]\n[Black \"Reshevsky, Samuel Herman\"]\n[Result \"1/2-1/2\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C81\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Nxe4 6.d4 b5 7.Bb3 d5 8.dxe5 Be6\n9.Qe2 Nc5 10.Rd1 b4 11.Be3 Nxb3 12.axb3 Qc8 13.c4 dxc4 14.bxc4 h6 15.Nbd2 Be7\n16.Nb3 O-O 17.Bc5 Bg4 18.Qe4 Bxf3 19.gxf3 Qe6 20.Bxe7 Nxe7 21.Nc5 Qg6+ 22.Qxg6 Nxg6\n23.Nxa6 Nxe5 24.b3 Ra7 25.Nxb4 Rxa1 26.Rxa1 Rb8 27.Nd5 Rxb3 28.f4 Nxc4 29.Rc1 Nd2\n30.Rxc7 Nf3+ 31.Kg2 Nh4+ 32.Kf1 Rb2 33.Ne3 Rb4 34.f5 f6 35.Rc5 Kh7 36.Nd5 Rd4\n37.Ne7 Re4 38.Ng6 Nxg6 39.fxg6+ Kxg6 40.Kg2 h5 41.h3 f5 42.Rc6+ Kg5 43.Rc7 g6\n44.Rc6 Rd4 45.Ra6 h4 46.Rb6 Rd3 47.Ra6 Kh5 48.Ra8 Rd6 49.Rh8+ Kg5 50.Kf3 Rd3+\n51.Kg2 Rd4 52.Kf3 Kf6 53.Rf8+ Kg7 54.Ra8 Rd3+ 55.Kg2 g5 56.Ra6 Rd7 57.Rb6 Re7\n58.Ra6 Kf7 59.Rh6 Re6 60.Rh8 Kg7 61.Rh5 Kg6 62.Rh8 Rc6 63.Rg8+ Kf6 64.Rf8+ Ke5\n65.Rg8 Kf4 66.Rh8 Rc5 67.Rh5 Rc6 68.Rh8 Rg6 69.Rh7 Ke5 70.Rh8 Kf6 71.Rf8+ Ke6\n72.Rh8 Kf7 73.f4 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "1951-botvinnik-bronstein-12",
|
||||
matchSlug: "1951-botvinnik-bronstein",
|
||||
matchLabel: "1951 — Botvinnik vs Bronstein",
|
||||
matchYear: 1951,
|
||||
gameNumber: 12,
|
||||
white: "Bronstein, David I",
|
||||
black: "Botvinnik, Mikhail",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 19th\"]\n[Site \"Moscow\"]\n[Date \"1951.??.??\"]\n[Round \"12\"]\n[White \"Bronstein, David I\"]\n[Black \"Botvinnik, Mikhail\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"A85\"]\n\n1.d4 e6 2.c4 f5 3.e3 Nf6 4.Nc3 d5 5.Nh3 c6 6.Bd2 Bd6 7.Qc2 O-O 8.O-O-O Qe7\n9.f3 dxc4 10.e4 fxe4 11.Nxe4 b5 12.Nxd6 Qxd6 13.f4 Na6 14.Be2 c5 15.Bf3 Rb8\n16.Bc3 Nb4 17.dxc5 Nxa2+ 18.Kb1 Nxc3+ 19.Qxc3 Qxc5 20.Rhe1 h6 21.Re5 Qc7\n22.g4 Bb7 23.Bxb7 Rxb7 24.g5 Nd5 25.Rdxd5 exd5 26.Qd4 c3 27.b3 Qd7 28.Nf2 c2+\n29.Kc1 hxg5 30.Rxg5 Qe6 31.Re5 Qd6 32.Kxc2 Rc7+ 33.Kd2 Qc5 34.Qxc5 Rxc5 35.Nd3 Rc6\n36.Rxd5 a6 37.h4 Rh6 38.h5 Rhf6 39.b4 Rf5 40.Rd6 R8f6 0-1",
|
||||
},
|
||||
{
|
||||
id: "1951-botvinnik-bronstein-17",
|
||||
matchSlug: "1951-botvinnik-bronstein",
|
||||
matchLabel: "1951 — Botvinnik vs Bronstein",
|
||||
matchYear: 1951,
|
||||
gameNumber: 17,
|
||||
white: "Botvinnik, Mikhail",
|
||||
black: "Bronstein, David I",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 19th\"]\n[Site \"Moscow\"]\n[Date \"1951.??.??\"]\n[Round \"17\"]\n[White \"Botvinnik, Mikhail\"]\n[Black \"Bronstein, David I\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"E45\"]\n\n1.d4 Nf6 2.c4 e6 3.Nc3 Bb4 4.e3 b6 5.Ne2 Ba6 6.a3 Be7 7.Ng3 d5 8.cxd5 Bxf1\n9.Nxf1 exd5 10.Ng3 Qd7 11.Qf3 Nc6 12.O-O g6 13.Bd2 O-O 14.Nce2 h5 15.Rfc1 h4\n16.Nf1 Ne4 17.Nf4 a5 18.Rc2 Bd8 19.Be1 Ne7 20.Qe2 Nd6 21.f3 g5 22.Nd3 Qe6\n23.a4 Ng6 24.h3 f5 25.Bc3 Bf6 26.Re1 Rae8 27.Qd1 Rf7 28.b3 Rfe7 29.Bb2 f4\n30.Ne5 Bxe5 31.dxe5 Nf7 32.exf4 Nxf4 33.Nh2 c5 34.Ng4 d4 35.Nf6+ Qxf6 0-1",
|
||||
},
|
||||
{
|
||||
id: "1951-botvinnik-bronstein-22",
|
||||
matchSlug: "1951-botvinnik-bronstein",
|
||||
matchLabel: "1951 — Botvinnik vs Bronstein",
|
||||
matchYear: 1951,
|
||||
gameNumber: 22,
|
||||
white: "Bronstein, David I",
|
||||
black: "Botvinnik, Mikhail",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 19th\"]\n[Site \"Moscow\"]\n[Date \"1951.??.??\"]\n[Round \"22\"]\n[White \"Bronstein, David I\"]\n[Black \"Botvinnik, Mikhail\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"A91\"]\n\n1.d4 e6 2.c4 f5 3.g3 Nf6 4.Bg2 Be7 5.Nc3 O-O 6.e3 d5 7.Nge2 c6 8.b3 Ne4 9.O-O Nd7\n10.Bb2 Ndf6 11.Qd3 g5 12.cxd5 exd5 13.f3 Nxc3 14.Bxc3 g4 15.fxg4 Nxg4 16.Bh3 Nh6\n17.Nf4 Bd6 18.b4 a6 19.a4 Qe7 20.Rab1 b5 21.Bg2 Ng4 22.Bd2 Nf6 23.Rb2 Bd7\n24.Ra1 Ne4 25.Be1 Rfe8 26.Qb3 Kh8 27.Rba2 Qf8 28.Nd3 Rab8 29.axb5 axb5 30.Ra7 Re7\n31.Ne5 Be8 32.g4 fxg4 33.Bxe4 dxe4 34.Bh4 Rxe5 35.dxe5 Bxe5 36.Rf1 Qg8 37.Bg3 Bg7\n38.Qxg8+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1960-tal-botvinnik-01",
|
||||
matchSlug: "1960-tal-botvinnik",
|
||||
matchLabel: "1960 — Tal vs Botvinnik",
|
||||
matchYear: 1960,
|
||||
gameNumber: 1,
|
||||
white: "Tal, Mihail",
|
||||
black: "Botvinnik, Mikhail",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 23th\"]\n[Site \"Moscow\"]\n[Date \"1960.??.??\"]\n[Round \"1\"]\n[White \"Tal, Mihail\"]\n[Black \"Botvinnik, Mikhail\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"C18\"]\n\n1.e4 e6 2.d4 d5 3.Nc3 Bb4 4.e5 c5 5.a3 Bxc3+ 6.bxc3 Qc7 7.Qg4 f5 8.Qg3 Ne7\n9.Qxg7 Rg8 10.Qxh7 cxd4 11.Kd1 Bd7 12.Qh5+ Ng6 13.Ne2 d3 14.cxd3 Ba4+ 15.Ke1 Qxe5\n16.Bg5 Nc6 17.d4 Qc7 18.h4 e5 19.Rh3 Qf7 20.dxe5 Ncxe5 21.Re3 Kd7 22.Rb1 b6\n23.Nf4 Rae8 24.Rb4 Bc6 25.Qd1 Nxf4 26.Rxf4 Ng6 27.Rd4 Rxe3+ 28.fxe3 Kc7 29.c4 dxc4\n30.Bxc4 Qg7 31.Bxg8 Qxg8 32.h5 1-0",
|
||||
},
|
||||
{
|
||||
id: "1960-tal-botvinnik-06",
|
||||
matchSlug: "1960-tal-botvinnik",
|
||||
matchLabel: "1960 — Tal vs Botvinnik",
|
||||
matchYear: 1960,
|
||||
gameNumber: 6,
|
||||
white: "Botvinnik, Mikhail",
|
||||
black: "Tal, Mihail",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 23th\"]\n[Site \"Moscow\"]\n[Date \"1960.??.??\"]\n[Round \"6\"]\n[White \"Botvinnik, Mikhail\"]\n[Black \"Tal, Mihail\"]\n[Result \"0-1\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"E69\"]\n\n1.c4 Nf6 2.Nf3 g6 3.g3 Bg7 4.Bg2 O-O 5.d4 d6 6.Nc3 Nbd7 7.O-O e5 8.e4 c6\n9.h3 Qb6 10.d5 cxd5 11.cxd5 Nc5 12.Ne1 Bd7 13.Nd3 Nxd3 14.Qxd3 Rfc8 15.Rb1 Nh5\n16.Be3 Qb4 17.Qe2 Rc4 18.Rfc1 Rac8 19.Kh2 f5 20.exf5 Bxf5 21.Ra1 Nf4 22.gxf4 exf4\n23.Bd2 Qxb2 24.Rab1 f3 25.Rxb2 fxe2 26.Rb3 Rd4 27.Be1 Be5+ 28.Kg1 Bf4 29.Nxe2 Rxc1\n30.Nxd4 Rxe1+ 31.Bf1 Be4 32.Ne2 Be5 33.f4 Bf6 34.Rxb7 Bxd5 35.Rc7 Bxa2 36.Rxa7 Bc4\n37.Ra8+ Kf7 38.Ra7+ Ke6 39.Ra3 d5 40.Kf2 Bh4+ 41.Kg2 Kd6 42.Ng3 Bxg3 43.Bxc4 dxc4\n44.Kxg3 Kd5 45.Ra7 c3 46.Rc7 Kd4 0-1",
|
||||
},
|
||||
{
|
||||
id: "1960-tal-botvinnik-17",
|
||||
matchSlug: "1960-tal-botvinnik",
|
||||
matchLabel: "1960 — Tal vs Botvinnik",
|
||||
matchYear: 1960,
|
||||
gameNumber: 17,
|
||||
white: "Tal, Mihail",
|
||||
black: "Botvinnik, Mikhail",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 23th\"]\n[Site \"Moscow\"]\n[Date \"1960.??.??\"]\n[Round \"17\"]\n[White \"Tal, Mihail\"]\n[Black \"Botvinnik, Mikhail\"]\n[Result \"1-0\"]\n[WhiteElo \"\"]\n[BlackElo \"\"]\n[ECO \"B18\"]\n\n1.e4 c6 2.d4 d5 3.Nc3 dxe4 4.Nxe4 Bf5 5.Ng3 Bg6 6.Bc4 e6 7.N1e2 Nf6 8.Nf4 Bd6\n9.Nxg6 hxg6 10.Bg5 Nbd7 11.O-O Qa5 12.f4 O-O-O 13.a3 Qc7 14.b4 Nb6 15.Be2 Be7\n16.Qd3 Nfd5 17.Bxe7 Qxe7 18.c4 Nf6 19.Rab1 Qd7 20.Rbd1 Kb8 21.Qb3 Qc7 22.a4 Rh4\n23.a5 Nc8 24.Qe3 Ne7 25.Qe5 Rhh8 26.b5 cxb5 27.Qxb5 a6 28.Qb2 Rd7 29.c5 Ka8\n30.Bf3 Nc6 31.Bxc6 Qxc6 32.Rf3 Qa4 33.Rfd3 Rc8 34.Rb1 Qxa5 35.Rb3 Qc7 36.Qa3 Ka7\n37.Rb6 Qxf4 38.Ne2 Qe4 39.Qb3 Qd5 40.Rxa6+ Kb8 41.Qa4 1-0",
|
||||
},
|
||||
{
|
||||
id: "1972-fischer-spassky-01",
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
gameNumber: 1,
|
||||
white: "Spassky, Boris V",
|
||||
black: "Fischer, Robert James",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 28th\"]\n[Site \"Reykjavik\"]\n[Date \"1972.??.??\"]\n[Round \"1\"]\n[White \"Spassky, Boris V\"]\n[Black \"Fischer, Robert James\"]\n[Result \"1-0\"]\n[WhiteElo \"2660\"]\n[BlackElo \"2785\"]\n[ECO \"E56\"]\n\n1.d4 Nf6 2.c4 e6 3.Nf3 d5 4.Nc3 Bb4 5.e3 O-O 6.Bd3 c5 7.O-O Nc6 8.a3 Ba5\n9.Ne2 dxc4 10.Bxc4 Bb6 11.dxc5 Qxd1 12.Rxd1 Bxc5 13.b4 Be7 14.Bb2 Bd7 15.Rac1 Rfd8\n16.Ned4 Nxd4 17.Nxd4 Ba4 18.Bb3 Bxb3 19.Nxb3 Rxd1+ 20.Rxd1 Rc8 21.Kf1 Kf8\n22.Ke2 Ne4 23.Rc1 Rxc1 24.Bxc1 f6 25.Na5 Nd6 26.Kd3 Bd8 27.Nc4 Bc7 28.Nxd6 Bxd6\n29.b5 Bxh2 30.g3 h5 31.Ke2 h4 32.Kf3 Ke7 33.Kg2 hxg3 34.fxg3 Bxg3 35.Kxg3 Kd6\n36.a4 Kd5 37.Ba3 Ke4 38.Bc5 a6 39.b6 f5 40.Kh4 f4 41.exf4 Kxf4 42.Kh5 Kf5\n43.Be3 Ke4 44.Bf2 Kf5 45.Bh4 e5 46.Bg5 e4 47.Be3 Kf6 48.Kg4 Ke5 49.Kg5 Kd5\n50.Kf5 a5 51.Bf2 g5 52.Kxg5 Kc4 53.Kf5 Kb4 54.Kxe4 Kxa4 55.Kd5 Kb5 56.Kd6 1-0",
|
||||
},
|
||||
{
|
||||
id: "1972-fischer-spassky-03",
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
gameNumber: 3,
|
||||
white: "Spassky, Boris V",
|
||||
black: "Fischer, Robert James",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 28th\"]\n[Site \"Reykjavik\"]\n[Date \"1972.??.??\"]\n[Round \"3\"]\n[White \"Spassky, Boris V\"]\n[Black \"Fischer, Robert James\"]\n[Result \"0-1\"]\n[WhiteElo \"2660\"]\n[BlackElo \"2785\"]\n[ECO \"A77\"]\n\n1.d4 Nf6 2.c4 e6 3.Nf3 c5 4.d5 exd5 5.cxd5 d6 6.Nc3 g6 7.Nd2 Nbd7 8.e4 Bg7\n9.Be2 O-O 10.O-O Re8 11.Qc2 Nh5 12.Bxh5 gxh5 13.Nc4 Ne5 14.Ne3 Qh4 15.Bd2 Ng4\n16.Nxg4 hxg4 17.Bf4 Qf6 18.g3 Bd7 19.a4 b6 20.Rfe1 a6 21.Re2 b5 22.Rae1 Qg6\n23.b3 Re7 24.Qd3 Rb8 25.axb5 axb5 26.b4 c4 27.Qd2 Rbe8 28.Re3 h5 29.R3e2 Kh7\n30.Re3 Kg8 31.R3e2 Bxc3 32.Qxc3 Rxe4 33.Rxe4 Rxe4 34.Rxe4 Qxe4 35.Bh6 Qg6\n36.Bc1 Qb1 37.Kf1 Bf5 38.Ke2 Qe4+ 39.Qe3 Qc2+ 40.Qd2 Qb3 41.Qd4 Bd3+ 0-1",
|
||||
},
|
||||
{
|
||||
id: "1972-fischer-spassky-06",
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
gameNumber: 6,
|
||||
white: "Fischer, Robert James",
|
||||
black: "Spassky, Boris V",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 28th\"]\n[Site \"Reykjavik\"]\n[Date \"1972.??.??\"]\n[Round \"6\"]\n[White \"Fischer, Robert James\"]\n[Black \"Spassky, Boris V\"]\n[Result \"1-0\"]\n[WhiteElo \"2785\"]\n[BlackElo \"2660\"]\n[ECO \"D59\"]\n\n1.c4 e6 2.Nf3 d5 3.d4 Nf6 4.Nc3 Be7 5.Bg5 O-O 6.e3 h6 7.Bh4 b6 8.cxd5 Nxd5\n9.Bxe7 Qxe7 10.Nxd5 exd5 11.Rc1 Be6 12.Qa4 c5 13.Qa3 Rc8 14.Bb5 a6 15.dxc5 bxc5\n16.O-O Ra7 17.Be2 Nd7 18.Nd4 Qf8 19.Nxe6 fxe6 20.e4 d4 21.f4 Qe7 22.e5 Rb8\n23.Bc4 Kh8 24.Qh3 Nf8 25.b3 a5 26.f5 exf5 27.Rxf5 Nh7 28.Rcf1 Qd8 29.Qg3 Re7\n30.h4 Rbb7 31.e6 Rbc7 32.Qe5 Qe8 33.a4 Qd8 34.R1f2 Qe8 35.R2f3 Qd8 36.Bd3 Qe8\n37.Qe4 Nf6 38.Rxf6 gxf6 39.Rxf6 Kg8 40.Bc4 Kh8 41.Qf4 1-0",
|
||||
},
|
||||
{
|
||||
id: "1972-fischer-spassky-08",
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
gameNumber: 8,
|
||||
white: "Fischer, Robert James",
|
||||
black: "Spassky, Boris V",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 28th\"]\n[Site \"Reykjavik\"]\n[Date \"1972.??.??\"]\n[Round \"8\"]\n[White \"Fischer, Robert James\"]\n[Black \"Spassky, Boris V\"]\n[Result \"1-0\"]\n[WhiteElo \"2785\"]\n[BlackElo \"2660\"]\n[ECO \"A39\"]\n\n1.c4 c5 2.Nc3 Nc6 3.Nf3 Nf6 4.g3 g6 5.Bg2 Bg7 6.O-O O-O 7.d4 cxd4 8.Nxd4 Nxd4\n9.Qxd4 d6 10.Bg5 Be6 11.Qf4 Qa5 12.Rac1 Rab8 13.b3 Rfc8 14.Qd2 a6 15.Be3 b5\n16.Ba7 bxc4 17.Bxb8 Rxb8 18.bxc4 Bxc4 19.Rfd1 Nd7 20.Nd5 Qxd2 21.Nxe7+ Kf8\n22.Rxd2 Kxe7 23.Rxc4 Rb1+ 24.Bf1 Nc5 25.Kg2 a5 26.e4 Ba1 27.f4 f6 28.Re2 Ke6\n29.Rec2 Bb2 30.Be2 h5 31.Rd2 Ba3 32.f5+ gxf5 33.exf5+ Ke5 34.Rcd4 Kxf5 35.Rd5+ Ke6\n36.Rxd6+ Ke7 37.Rc6 1-0",
|
||||
},
|
||||
{
|
||||
id: "1972-fischer-spassky-13",
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
gameNumber: 13,
|
||||
white: "Spassky, Boris V",
|
||||
black: "Fischer, Robert James",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 28th\"]\n[Site \"Reykjavik\"]\n[Date \"1972.??.??\"]\n[Round \"13\"]\n[White \"Spassky, Boris V\"]\n[Black \"Fischer, Robert James\"]\n[Result \"0-1\"]\n[WhiteElo \"2660\"]\n[BlackElo \"2785\"]\n[ECO \"B04\"]\n\n1.e4 Nf6 2.e5 Nd5 3.d4 d6 4.Nf3 g6 5.Bc4 Nb6 6.Bb3 Bg7 7.Nbd2 O-O 8.h3 a5\n9.a4 dxe5 10.dxe5 Na6 11.O-O Nc5 12.Qe2 Qe8 13.Ne4 Nbxa4 14.Bxa4 Nxa4 15.Re1 Nb6\n16.Bd2 a4 17.Bg5 h6 18.Bh4 Bf5 19.g4 Be6 20.Nd4 Bc4 21.Qd2 Qd7 22.Rad1 Rfe8\n23.f4 Bd5 24.Nc5 Qc8 25.Qc3 e6 26.Kh2 Nd7 27.Nd3 c5 28.Nb5 Qc6 29.Nd6 Qxd6\n30.exd6 Bxc3 31.bxc3 f6 32.g5 hxg5 33.fxg5 f5 34.Bg3 Kf7 35.Ne5+ Nxe5 36.Bxe5 b5\n37.Rf1 Rh8 38.Bf6 a3 39.Rf4 a2 40.c4 Bxc4 41.d7 Bd5 42.Kg3 Ra3+ 43.c3 Rha8\n44.Rh4 e5 45.Rh7+ Ke6 46.Re7+ Kd6 47.Rxe5 Rxc3+ 48.Kf2 Rc2+ 49.Ke1 Kxd7 50.Rexd5+ Kc6\n51.Rd6+ Kb7 52.Rd7+ Ka6 53.R7d2 Rxd2 54.Kxd2 b4 55.h4 Kb5 56.h5 c4 57.Ra1 gxh5\n58.g6 h4 59.g7 h3 60.Be7 Rg8 61.Bf8 h2 62.Kc2 Kc6 63.Rd1 b3+ 64.Kc3 h1=Q\n65.Rxh1 Kd5 66.Kb2 f4 67.Rd1+ Ke4 68.Rc1 Kd3 69.Rd1+ Ke2 70.Rc1 f3 71.Bc5 Rxg7\n72.Rxc4 Rd7 73.Re4+ Kf1 74.Bd4 f2 0-1",
|
||||
},
|
||||
{
|
||||
id: "1972-fischer-spassky-21",
|
||||
matchSlug: "1972-fischer-spassky",
|
||||
matchLabel: "1972 — Fischer vs Spassky",
|
||||
matchYear: 1972,
|
||||
gameNumber: 21,
|
||||
white: "Spassky, Boris V",
|
||||
black: "Fischer, Robert James",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 28th\"]\n[Site \"Reykjavik\"]\n[Date \"1972.??.??\"]\n[Round \"21\"]\n[White \"Spassky, Boris V\"]\n[Black \"Fischer, Robert James\"]\n[Result \"0-1\"]\n[WhiteElo \"2660\"]\n[BlackElo \"2785\"]\n[ECO \"B46\"]\n\n1.e4 c5 2.Nf3 e6 3.d4 cxd4 4.Nxd4 a6 5.Nc3 Nc6 6.Be3 Nf6 7.Bd3 d5 8.exd5 exd5\n9.O-O Bd6 10.Nxc6 bxc6 11.Bd4 O-O 12.Qf3 Be6 13.Rfe1 c5 14.Bxf6 Qxf6 15.Qxf6 gxf6\n16.Rad1 Rfd8 17.Be2 Rab8 18.b3 c4 19.Nxd5 Bxd5 20.Rxd5 Bxh2+ 21.Kxh2 Rxd5\n22.Bxc4 Rd2 23.Bxa6 Rxc2 24.Re2 Rxe2 25.Bxe2 Rd8 26.a4 Rd2 27.Bc4 Ra2 28.Kg3 Kf8\n29.Kf3 Ke7 30.g4 f5 31.gxf5 f6 32.Bg8 h6 33.Kg3 Kd6 34.Kf3 Ra1 35.Kg2 Ke5\n36.Be6 Kf4 37.Bd7 Rb1 38.Be6 Rb2 39.Bc4 Ra2 40.Be6 h5 41.Bd7 0-1",
|
||||
},
|
||||
{
|
||||
id: "1985-kasparov-karpov-04",
|
||||
matchSlug: "1985-kasparov-karpov",
|
||||
matchLabel: "1985 — Kasparov vs Karpov",
|
||||
matchYear: 1985,
|
||||
gameNumber: 4,
|
||||
white: "Karpov, Anatoly",
|
||||
black: "Kasparov, Gary",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 32th-KK2\"]\n[Site \"Moscow\"]\n[Date \"1985.??.??\"]\n[Round \"4\"]\n[White \"Karpov, Anatoly\"]\n[Black \"Kasparov, Gary\"]\n[Result \"1-0\"]\n[WhiteElo \"2720\"]\n[BlackElo \"2700\"]\n[ECO \"D55\"]\n\n1.d4 d5 2.c4 e6 3.Nc3 Be7 4.Nf3 Nf6 5.Bg5 h6 6.Bxf6 Bxf6 7.e3 O-O 8.Qc2 Na6\n9.Rd1 c5 10.dxc5 Qa5 11.cxd5 Nxc5 12.Qd2 Rd8 13.Nd4 exd5 14.Be2 Qb6 15.O-O Ne4\n16.Qc2 Nxc3 17.Qxc3 Be6 18.Qc2 Rac8 19.Qb1 Rc7 20.Rd2 Rdc8 21.Nxe6 fxe6 22.Bg4 Rc4\n23.h3 Qc6 24.Qd3 Kh8 25.Rfd1 a5 26.b3 Rc3 27.Qe2 Rf8 28.Bh5 b5 29.Bg6 Bd8\n30.Bd3 b4 31.Qg4 Qe8 32.e4 Bg5 33.Rc2 Rxc2 34.Bxc2 Qc6 35.Qe2 Qc5 36.Rf1 Qc3\n37.exd5 exd5 38.Bb1 Qd2 39.Qe5 Rd8 40.Qf5 Kg8 41.Qe6+ Kh8 42.Qg6 Kg8 43.Qe6+ Kh8\n44.Bf5 Qc3 45.Qg6 Kg8 46.Be6+ Kh8 47.Bf5 Kg8 48.g3 Kf8 49.Kg2 Qf6 50.Qh7 Qf7\n51.h4 Bd2 52.Rd1 Bc3 53.Rd3 Rd6 54.Rf3 Ke7 55.Qh8 d4 56.Qc8 Rf6 57.Qc5+ Ke8\n58.Rf4 Qb7+ 59.Re4+ Kf7 60.Qc4+ Kf8 61.Bh7 Rf7 62.Qe6 Qd7 63.Qe5 1-0",
|
||||
},
|
||||
{
|
||||
id: "1985-kasparov-karpov-11",
|
||||
matchSlug: "1985-kasparov-karpov",
|
||||
matchLabel: "1985 — Kasparov vs Karpov",
|
||||
matchYear: 1985,
|
||||
gameNumber: 11,
|
||||
white: "Kasparov, Gary",
|
||||
black: "Karpov, Anatoly",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 32th-KK2\"]\n[Site \"Moscow\"]\n[Date \"1985.??.??\"]\n[Round \"11\"]\n[White \"Kasparov, Gary\"]\n[Black \"Karpov, Anatoly\"]\n[Result \"1-0\"]\n[WhiteElo \"2700\"]\n[BlackElo \"2720\"]\n[ECO \"E21\"]\n\n1.d4 Nf6 2.c4 e6 3.Nc3 Bb4 4.Nf3 O-O 5.Bg5 c5 6.e3 cxd4 7.exd4 h6 8.Bh4 d5\n9.Rc1 dxc4 10.Bxc4 Nc6 11.O-O Be7 12.Re1 b6 13.a3 Bb7 14.Bg3 Rc8 15.Ba2 Bd6\n16.d5 Nxd5 17.Nxd5 Bxg3 18.hxg3 exd5 19.Bxd5 Qf6 20.Qa4 Rfd8 21.Rcd1 Rd7\n22.Qg4 Rcd8 23.Qxd7 Rxd7 24.Re8+ Kh7 25.Be4+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1985-kasparov-karpov-16",
|
||||
matchSlug: "1985-kasparov-karpov",
|
||||
matchLabel: "1985 — Kasparov vs Karpov",
|
||||
matchYear: 1985,
|
||||
gameNumber: 16,
|
||||
white: "Karpov, Anatoly",
|
||||
black: "Kasparov, Gary",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 32th-KK2\"]\n[Site \"Moscow\"]\n[Date \"1985.??.??\"]\n[Round \"16\"]\n[White \"Karpov, Anatoly\"]\n[Black \"Kasparov, Gary\"]\n[Result \"0-1\"]\n[WhiteElo \"2720\"]\n[BlackElo \"2700\"]\n[ECO \"B44\"]\n\n1.e4 c5 2.Nf3 e6 3.d4 cxd4 4.Nxd4 Nc6 5.Nb5 d6 6.c4 Nf6 7.N1c3 a6 8.Na3 d5\n9.cxd5 exd5 10.exd5 Nb4 11.Be2 Bc5 12.O-O O-O 13.Bf3 Bf5 14.Bg5 Re8 15.Qd2 b5\n16.Rad1 Nd3 17.Nab1 h6 18.Bh4 b4 19.Na4 Bd6 20.Bg3 Rc8 21.b3 g5 22.Bxd6 Qxd6\n23.g3 Nd7 24.Bg2 Qf6 25.a3 a5 26.axb4 axb4 27.Qa2 Bg6 28.d6 g4 29.Qd2 Kg7\n30.f3 Qxd6 31.fxg4 Qd4+ 32.Kh1 Nf6 33.Rf4 Ne4 34.Qxd3 Nf2+ 35.Rxf2 Bxd3 36.Rfd2 Qe3\n37.Rxd3 Rc1 38.Nb2 Qf2 39.Nd2 Rxd1+ 40.Nxd1 Re1+ 0-1",
|
||||
},
|
||||
{
|
||||
id: "1985-kasparov-karpov-19",
|
||||
matchSlug: "1985-kasparov-karpov",
|
||||
matchLabel: "1985 — Kasparov vs Karpov",
|
||||
matchYear: 1985,
|
||||
gameNumber: 19,
|
||||
white: "Kasparov, Gary",
|
||||
black: "Karpov, Anatoly",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 32th-KK2\"]\n[Site \"Moscow\"]\n[Date \"1985.??.??\"]\n[Round \"19\"]\n[White \"Kasparov, Gary\"]\n[Black \"Karpov, Anatoly\"]\n[Result \"1-0\"]\n[WhiteElo \"2700\"]\n[BlackElo \"2720\"]\n[ECO \"E21\"]\n\n1.d4 Nf6 2.c4 e6 3.Nc3 Bb4 4.Nf3 Ne4 5.Qc2 f5 6.g3 Nc6 7.Bg2 O-O 8.O-O Bxc3\n9.bxc3 Na5 10.c5 d6 11.c4 b6 12.Bd2 Nxd2 13.Nxd2 d5 14.cxd5 exd5 15.e3 Be6\n16.Qc3 Rf7 17.Rfc1 Rb8 18.Rab1 Re7 19.a4 Bf7 20.Bf1 h6 21.Bd3 Qd7 22.Qc2 Be6\n23.Bb5 Qd8 24.Rd1 g5 25.Nf3 Rg7 26.Ne5 f4 27.Bf1 Qf6 28.Bg2 Rd8 29.e4 dxe4\n30.Bxe4 Re7 31.Qc3 Bd5 32.Re1 Kg7 33.Ng4 Qf7 34.Bxd5 Rxd5 35.Rxe7 Qxe7 36.Re1 Qd8\n37.Ne5 Qf6 38.cxb6 Qxb6 39.gxf4 Rxd4 40.Nf3 Nb3 41.Rb1 Qf6 42.Qxc7+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "1985-kasparov-karpov-24",
|
||||
matchSlug: "1985-kasparov-karpov",
|
||||
matchLabel: "1985 — Kasparov vs Karpov",
|
||||
matchYear: 1985,
|
||||
gameNumber: 24,
|
||||
white: "Karpov, Anatoly",
|
||||
black: "Kasparov, Gary",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"World Championship 32th-KK2\"]\n[Site \"Moscow\"]\n[Date \"1985.??.??\"]\n[Round \"24\"]\n[White \"Karpov, Anatoly\"]\n[Black \"Kasparov, Gary\"]\n[Result \"0-1\"]\n[WhiteElo \"2720\"]\n[BlackElo \"2700\"]\n[ECO \"B85\"]\n\n1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be2 e6 7.O-O Be7 8.f4 O-O\n9.Kh1 Qc7 10.a4 Nc6 11.Be3 Re8 12.Bf3 Rb8 13.Qd2 Bd7 14.Nb3 b6 15.g4 Bc8\n16.g5 Nd7 17.Qf2 Bf8 18.Bg2 Bb7 19.Rad1 g6 20.Bc1 Rbc8 21.Rd3 Nb4 22.Rh3 Bg7\n23.Be3 Re7 24.Kg1 Rce8 25.Rd1 f5 26.gxf6 Nxf6 27.Rg3 Rf7 28.Bxb6 Qb8 29.Be3 Nh5\n30.Rg4 Nf6 31.Rh4 g5 32.fxg5 Ng4 33.Qd2 Nxe3 34.Qxe3 Nxc2 35.Qb6 Ba8 36.Rxd6 Rb7\n37.Qxa6 Rxb3 38.Rxe6 Rxb2 39.Qc4 Kh8 40.e5 Qa7+ 41.Kh1 Bxg2+ 42.Kxg2 Nd4+ 0-1",
|
||||
},
|
||||
{
|
||||
id: "1990-kasparov-karpov-02",
|
||||
matchSlug: "1990-kasparov-karpov",
|
||||
matchLabel: "1990 — Kasparov vs Karpov",
|
||||
matchYear: 1990,
|
||||
gameNumber: 2,
|
||||
white: "Kasparov, Gary",
|
||||
black: "Karpov, Anatoly",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 35th-KK5\"]\n[Site \"Lyon/New York\"]\n[Date \"1990.??.??\"]\n[Round \"2\"]\n[White \"Kasparov, Gary\"]\n[Black \"Karpov, Anatoly\"]\n[Result \"1-0\"]\n[WhiteElo \"2800\"]\n[BlackElo \"2730\"]\n[ECO \"C92\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Be7 6.Re1 b5 7.Bb3 d6 8.c3 O-O\n9.h3 Bb7 10.d4 Re8 11.Nbd2 Bf8 12.a4 h6 13.Bc2 exd4 14.cxd4 Nb4 15.Bb1 bxa4\n16.Rxa4 a5 17.Ra3 Ra6 18.Nh2 g6 19.f3 Qd7 20.Nc4 Qb5 21.Rc3 Bc8 22.Be3 Kh7\n23.Qc1 c6 24.Ng4 Ng8 25.Bxh6 Bxh6 26.Nxh6 Nxh6 27.Nxd6 Qb6 28.Nxe8 Qxd4+\n29.Kh1 Qd8 30.Rd1 Qxe8 31.Qg5 Ra7 32.Rd8 Qe6 33.f4 Ba6 34.f5 Qe7 35.Qd2 Qe5\n36.Qf2 Qe7 37.Qd4 Ng8 38.e5 Nd5 39.fxg6+ fxg6 40.Rxc6 Qxd8 41.Qxa7+ Nde7\n42.Rxa6 Qd1+ 43.Qg1 Qd2 44.Qf1 1-0",
|
||||
},
|
||||
{
|
||||
id: "1990-kasparov-karpov-18",
|
||||
matchSlug: "1990-kasparov-karpov",
|
||||
matchLabel: "1990 — Kasparov vs Karpov",
|
||||
matchYear: 1990,
|
||||
gameNumber: 18,
|
||||
white: "Kasparov, Gary",
|
||||
black: "Karpov, Anatoly",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 35th-KK5\"]\n[Site \"Lyon/New York\"]\n[Date \"1990.??.??\"]\n[Round \"18\"]\n[White \"Kasparov, Gary\"]\n[Black \"Karpov, Anatoly\"]\n[Result \"1-0\"]\n[WhiteElo \"2800\"]\n[BlackElo \"2730\"]\n[ECO \"C92\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Be7 6.Re1 b5 7.Bb3 d6 8.c3 O-O\n9.h3 Nd7 10.d4 Bf6 11.a4 Bb7 12.Na3 exd4 13.cxd4 Nb6 14.Bf4 bxa4 15.Bxa4 Nxa4\n16.Qxa4 a5 17.Bd2 Re8 18.d5 Nb4 19.Bxb4 axb4 20.Qxb4 Rb8 21.Qc4 Qc8 22.Nd4 Ba6\n23.Qc3 c5 24.dxc6 Bxd4 25.Qxd4 Qxc6 26.b4 h6 27.Re3 Re6 28.f3 Rc8 29.Rb3 Bb5\n30.Rb2 Qb7 31.Nc2 Qe7 32.Qf2 Rg6 33.Ne3 Qe5 34.Rbb1 Bd7 35.Ra5 Qe7 36.Ra7 Qd8\n37.Nd5 Kh7 38.Kh2 Rb8 39.f4 Re6 40.Qd4 Qe8 41.Re1 Bc6 42.Qd3 Qf8 43.Rc1 Bxd5\n44.exd5+ Rg6 45.Qf5 Kg8 46.Rac7 Rf6 47.Qd7 Rd8 48.Qxd8 Qxd8 49.Rc8 Qf8 50.R1c4 Rf5\n51.Rxf8+ Kxf8 52.Rd4 h5 53.b5 Ke7 54.b6 Kd7 55.g4 hxg4 56.hxg4 Rf6 57.Rc4 1-0",
|
||||
},
|
||||
{
|
||||
id: "1990-kasparov-karpov-20",
|
||||
matchSlug: "1990-kasparov-karpov",
|
||||
matchLabel: "1990 — Kasparov vs Karpov",
|
||||
matchYear: 1990,
|
||||
gameNumber: 20,
|
||||
white: "Kasparov, Gary",
|
||||
black: "Karpov, Anatoly",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"World Championship 35th-KK5\"]\n[Site \"Lyon/New York\"]\n[Date \"1990.??.??\"]\n[Round \"20\"]\n[White \"Kasparov, Gary\"]\n[Black \"Karpov, Anatoly\"]\n[Result \"1-0\"]\n[WhiteElo \"2800\"]\n[BlackElo \"2730\"]\n[ECO \"C92\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Be7 6.Re1 b5 7.Bb3 d6 8.c3 O-O\n9.h3 Bb7 10.d4 Re8 11.Nbd2 Bf8 12.a4 h6 13.Bc2 exd4 14.cxd4 Nb4 15.Bb1 c5\n16.d5 Nd7 17.Ra3 f5 18.Rae3 Nf6 19.Nh2 Kh8 20.b3 bxa4 21.bxa4 c4 22.Bb2 fxe4\n23.Nxe4 Nfxd5 24.Rg3 Re6 25.Ng4 Qe8 26.Nxh6 c3 27.Nf5 cxb2 28.Qg4 Bc8 29.Qh4+ Rh6\n30.Nxh6 gxh6 31.Kh2 Qe5 32.Ng5 Qf6 33.Re8 Bf5 34.Qxh6+ Qxh6 35.Nf7+ Kh7 36.Bxf5+ Qg6\n37.Bxg6+ Kg7 38.Rxa8 Be7 39.Rb8 a5 40.Be4+ Kxf7 41.Bxd5+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "2000-kramnik-kasparov-01",
|
||||
matchSlug: "2000-kramnik-kasparov",
|
||||
matchLabel: "2000 — Kramnik vs Kasparov",
|
||||
matchYear: 2000,
|
||||
gameNumber: 1,
|
||||
white: "Kasparov,G",
|
||||
black: "Kramnik,V",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"Braingames WCC\"]\n[Site \"London ENG\"]\n[Date \"2000.10.08\"]\n[Round \"1\"]\n[White \"Kasparov,G\"]\n[Black \"Kramnik,V\"]\n[Result \"1/2-1/2\"]\n[WhiteElo \"2849\"]\n[BlackElo \"2770\"]\n[ECO \"C67\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.d4 Nd6 6.Bxc6 dxc6 7.dxe5 Nf5 8.Qxd8+ Kxd8\n9.Nc3 Bd7 10.b3 h6 11.Bb2 Kc8 12.h3 b6 13.Rad1 Ne7 14.Ne2 Ng6 15.Ne1 h5 16.Nd3 c5\n17.c4 a5 18.a4 h4 19.Nc3 Be6 20.Nd5 Kb7 21.Ne3 Rh5 22.Bc3 Re8 23.Rd2 Kc8\n24.f4 Ne7 25.Nf2 Nf5 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "2000-kramnik-kasparov-02",
|
||||
matchSlug: "2000-kramnik-kasparov",
|
||||
matchLabel: "2000 — Kramnik vs Kasparov",
|
||||
matchYear: 2000,
|
||||
gameNumber: 2,
|
||||
white: "Kramnik,V",
|
||||
black: "Kasparov,G",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"Braingames WCC\"]\n[Site \"London ENG\"]\n[Date \"2000.10.10\"]\n[Round \"2\"]\n[White \"Kramnik,V\"]\n[Black \"Kasparov,G\"]\n[Result \"1-0\"]\n[WhiteElo \"2770\"]\n[BlackElo \"2849\"]\n[ECO \"D85\"]\n\n1.d4 Nf6 2.c4 g6 3.Nc3 d5 4.cxd5 Nxd5 5.e4 Nxc3 6.bxc3 Bg7 7.Nf3 c5 8.Be3 Qa5\n9.Qd2 Bg4 10.Rb1 a6 11.Rxb7 Bxf3 12.gxf3 Nc6 13.Bc4 O-O 14.O-O cxd4 15.cxd4 Bxd4\n16.Bd5 Bc3 17.Qc1 Nd4 18.Bxd4 Bxd4 19.Rxe7 Ra7 20.Rxa7 Bxa7 21.f4 Qd8 22.Qc3 Bb8\n23.Qf3 Qh4 24.e5 g5 25.Re1 Qxf4 26.Qxf4 gxf4 27.e6 fxe6 28.Rxe6 Kg7 29.Rxa6 Rf5\n30.Be4 Re5 31.f3 Re7 32.a4 Ra7 33.Rb6 Be5 34.Rb4 Rd7 35.Kg2 Rd2+ 36.Kh3 h5\n37.Rb5 Kf6 38.a5 Ra2 39.Rb6+ Ke7 40.Bd5 1-0",
|
||||
},
|
||||
{
|
||||
id: "2000-kramnik-kasparov-10",
|
||||
matchSlug: "2000-kramnik-kasparov",
|
||||
matchLabel: "2000 — Kramnik vs Kasparov",
|
||||
matchYear: 2000,
|
||||
gameNumber: 10,
|
||||
white: "Kramnik,V",
|
||||
black: "Kasparov,G",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"Braingames WCC\"]\n[Site \"London ENG\"]\n[Date \"2000.10.24\"]\n[Round \"10\"]\n[White \"Kramnik,V\"]\n[Black \"Kasparov,G\"]\n[Result \"1-0\"]\n[WhiteElo \"2770\"]\n[BlackElo \"2849\"]\n[ECO \"E54\"]\n\n1.d4 Nf6 2.c4 e6 3.Nc3 Bb4 4.e3 O-O 5.Bd3 d5 6.Nf3 c5 7.O-O cxd4 8.exd4 dxc4\n9.Bxc4 b6 10.Bg5 Bb7 11.Re1 Nbd7 12.Rc1 Rc8 13.Qb3 Be7 14.Bxf6 Nxf6 15.Bxe6 fxe6\n16.Qxe6+ Kh8 17.Qxe7 Bxf3 18.gxf3 Qxd4 19.Nb5 Qxb2 20.Rxc8 Rxc8 21.Nd6 Rb8\n22.Nf7+ Kg8 23.Qe6 Rf8 24.Nd8+ Kh8 25.Qe7 1-0",
|
||||
},
|
||||
{
|
||||
id: "2000-kramnik-kasparov-13",
|
||||
matchSlug: "2000-kramnik-kasparov",
|
||||
matchLabel: "2000 — Kramnik vs Kasparov",
|
||||
matchYear: 2000,
|
||||
gameNumber: 13,
|
||||
white: "Kasparov,G",
|
||||
black: "Kramnik,V",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"Braingames WCC\"]\n[Site \"London ENG\"]\n[Date \"2000.10.29\"]\n[Round \"13\"]\n[White \"Kasparov,G\"]\n[Black \"Kramnik,V\"]\n[Result \"1/2-1/2\"]\n[WhiteElo \"2849\"]\n[BlackElo \"2770\"]\n[ECO \"C67\"]\n\n1.e4 e5 2.Nf3 Nc6 3.Bb5 Nf6 4.O-O Nxe4 5.d4 Nd6 6.Bxc6 dxc6 7.dxe5 Nf5 8.Qxd8+ Kxd8\n9.Nc3 h6 10.h3 Ke8 11.Ne4 c5 12.c3 b6 13.Re1 Be6 14.g4 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "2008-anand-kramnik-03",
|
||||
matchSlug: "2008-anand-kramnik",
|
||||
matchLabel: "2008 — Anand vs Kramnik",
|
||||
matchYear: 2008,
|
||||
gameNumber: 3,
|
||||
white: "Kramnik,V",
|
||||
black: "Anand,V",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh\"]\n[Site \"Bonn GER\"]\n[Date \"2008.10.17\"]\n[Round \"3\"]\n[White \"Kramnik,V\"]\n[Black \"Anand,V\"]\n[Result \"0-1\"]\n[WhiteElo \"2772\"]\n[BlackElo \"2783\"]\n[EventDate \"2008.10.14\"]\n[ECO \"D49\"]\n\n1. d4 d5 2. c4 c6 3. Nf3 Nf6 4. Nc3 e6 5. e3 Nbd7 6. Bd3 dxc4 7. Bxc4 b5 8.\nBd3 a6 9. e4 c5 10. e5 cxd4 11. Nxb5 axb5 12. exf6 gxf6 13. O-O Qb6 14. Qe2\nBb7 15. Bxb5 Bd6 16. Rd1 Rg8 17. g3 Rg4 18. Bf4 Bxf4 19. Nxd4 h5 20. Nxe6\nfxe6 21. Rxd7 Kf8 22. Qd3 Rg7 23. Rxg7 Kxg7 24. gxf4 Rd8 25. Qe2 Kh6 26.\nKf1 Rg8 27. a4 Bg2+ 28. Ke1 Bh3 29. Ra3 Rg1+ 30. Kd2 Qd4+ 31. Kc2 Bg4 32.\nf3 Bf5+ 33. Bd3 Bh3 34. a5 Rg2 35. a6 Rxe2+ 36. Bxe2 Bf5+ 37. Kb3 Qe3+ 38.\nKa2 Qxe2 39. a7 Qc4+ 40. Ka1 Qf1+ 41. Ka2 Bb1+ 0-1",
|
||||
},
|
||||
{
|
||||
id: "2008-anand-kramnik-05",
|
||||
matchSlug: "2008-anand-kramnik",
|
||||
matchLabel: "2008 — Anand vs Kramnik",
|
||||
matchYear: 2008,
|
||||
gameNumber: 5,
|
||||
white: "Kramnik,V",
|
||||
black: "Anand,V",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh\"]\n[Site \"Bonn GER\"]\n[Date \"2008.10.20\"]\n[Round \"5\"]\n[White \"Kramnik,V\"]\n[Black \"Anand,V\"]\n[Result \"0-1\"]\n[WhiteElo \"2772\"]\n[BlackElo \"2783\"]\n[EventDate \"2008.10.14\"]\n[ECO \"D49\"]\n\n1. d4 d5 2. c4 c6 3. Nf3 Nf6 4. Nc3 e6 5. e3 Nbd7 6. Bd3 dxc4 7. Bxc4 b5 8.\nBd3 a6 9. e4 c5 10. e5 cxd4 11. Nxb5 axb5 12. exf6 gxf6 13. O-O Qb6 14. Qe2\nBb7 15. Bxb5 Rg8 16. Bf4 Bd6 17. Bg3 f5 18. Rfc1 f4 19. Bh4 Be7 20. a4 Bxh4\n21. Nxh4 Ke7 22. Ra3 Rac8 23. Rxc8 Rxc8 24. Ra1 Qc5 25. Qg4 Qe5 26. Nf3 Qf6\n27. Re1 Rc5 28. b4 Rc3 29. Nxd4 Qxd4 30. Rd1 Nf6 31. Rxd4 Nxg4 32. Rd7+ Kf6\n33. Rxb7 Rc1+ 34. Bf1 Ne3 35. fxe3 fxe3 0-1",
|
||||
},
|
||||
{
|
||||
id: "2008-anand-kramnik-06",
|
||||
matchSlug: "2008-anand-kramnik",
|
||||
matchLabel: "2008 — Anand vs Kramnik",
|
||||
matchYear: 2008,
|
||||
gameNumber: 6,
|
||||
white: "Anand,V",
|
||||
black: "Kramnik,V",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh\"]\n[Site \"Bonn GER\"]\n[Date \"2008.10.21\"]\n[Round \"6\"]\n[White \"Anand,V\"]\n[Black \"Kramnik,V\"]\n[Result \"1-0\"]\n[WhiteElo \"2783\"]\n[BlackElo \"2772\"]\n[EventDate \"2008.10.14\"]\n[ECO \"E34\"]\n\n1. d4 Nf6 2. c4 e6 3. Nc3 Bb4 4. Qc2 d5 5. cxd5 Qxd5 6. Nf3 Qf5 7. Qb3 Nc6\n8. Bd2 O-O 9. h3 b6 10. g4 Qa5 11. Rc1 Bb7 12. a3 Bxc3 13. Bxc3 Qd5 14.\nQxd5 Nxd5 15. Bd2 Nf6 16. Rg1 Rac8 17. Bg2 Ne7 18. Bb4 c5 19. dxc5 Rfd8 20.\nNe5 Bxg2 21. Rxg2 bxc5 22. Rxc5 Ne4 23. Rxc8 Rxc8 24. Nd3 Nd5 25. Bd2 Rc2\n26. Bc1 f5 27. Kd1 Rc8 28. f3 Nd6 29. Ke1 a5 30. e3 e5 31. gxf5 e4 32. fxe4\nNxe4 33. Bd2 a4 34. Nf2 Nd6 35. Rg4 Nc4 36. e4 Nf6 37. Rg3 Nxb2 38. e5 Nd5\n39. f6 Kf7 40. Ne4 Nc4 41. fxg7 Kg8 42. Rd3 Ndb6 43. Bh6 Nxe5 44. Nf6+ Kf7\n45. Rc3 Rxc3 46. g8=Q+ Kxf6 47. Bg7+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "2010-anand-topalov-04",
|
||||
matchSlug: "2010-anand-topalov",
|
||||
matchLabel: "2010 — Anand vs Topalov",
|
||||
matchYear: 2010,
|
||||
gameNumber: 4,
|
||||
white: "Anand,V",
|
||||
black: "Topalov,V",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh\"]\n[Site \"Sofia BUL\"]\n[Date \"2010.04.28\"]\n[Round \"4\"]\n[White \"Anand,V\"]\n[Black \"Topalov,V\"]\n[Result \"1-0\"]\n[WhiteElo \"2787\"]\n[BlackElo \"2805\"]\n[EventDate \"2010.04.24\"]\n[ECO \"E04\"]\n\n1. d4 Nf6 2. c4 e6 3. Nf3 d5 4. g3 dxc4 5. Bg2 Bb4+ 6. Bd2 a5 7. Qc2 Bxd2+\n8. Qxd2 c6 9. a4 b5 10. Na3 Bd7 11. Ne5 Nd5 12. e4 Nb4 13. O-O O-O 14. Rfd1\nBe8 15. d5 Qd6 16. Ng4 Qc5 17. Ne3 N8a6 18. dxc6 bxa4 19. Naxc4 Bxc6 20.\nRac1 h6 21. Nd6 Qa7 22. Ng4 Rad8 23. Nxh6+ gxh6 24. Qxh6 f6 25. e5 Bxg2 26.\nexf6 Rxd6 27. Rxd6 Be4 28. Rxe6 Nd3 29. Rc2 Qh7 30. f7+ Qxf7 31. Rxe4 Qf5\n32. Re7 1-0",
|
||||
},
|
||||
{
|
||||
id: "2010-anand-topalov-12",
|
||||
matchSlug: "2010-anand-topalov",
|
||||
matchLabel: "2010 — Anand vs Topalov",
|
||||
matchYear: 2010,
|
||||
gameNumber: 12,
|
||||
white: "Topalov,V",
|
||||
black: "Anand,V",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh\"]\n[Site \"Sofia BUL\"]\n[Date \"2010.05.11\"]\n[Round \"12\"]\n[White \"Topalov,V\"]\n[Black \"Anand,V\"]\n[Result \"0-1\"]\n[WhiteElo \"2805\"]\n[BlackElo \"2787\"]\n[EventDate \"2010.04.24\"]\n[ECO \"D56\"]\n\n1. d4 d5 2. c4 e6 3. Nf3 Nf6 4. Nc3 Be7 5. Bg5 h6 6. Bh4 O-O 7. e3 Ne4 8.\nBxe7 Qxe7 9. Rc1 c6 10. Be2 Nxc3 11. Rxc3 dxc4 12. Bxc4 Nd7 13. O-O b6 14.\nBd3 c5 15. Be4 Rb8 16. Qc2 Nf6 17. dxc5 Nxe4 18. Qxe4 bxc5 19. Qc2 Bb7 20.\nNd2 Rfd8 21. f3 Ba6 22. Rf2 Rd7 23. g3 Rbd8 24. Kg2 Bd3 25. Qc1 Ba6 26. Ra3\nBb7 27. Nb3 Rc7 28. Na5 Ba8 29. Nc4 e5 30. e4 f5 31. exf5 e4 32. fxe4 Qxe4+\n33. Kh3 Rd4 34. Ne3 Qe8 35. g4 h5 36. Kh4 g5+ 37. fxg6 Qxg6 38. Qf1 Rxg4+\n39. Kh3 Re7 40. Rf8+ Kg7 41. Nf5+ Kh7 42. Rg3 Rxg3+ 43. hxg3 Qg4+ 44. Kh2\nRe2+ 45. Kg1 Rg2+ 46. Qxg2 Bxg2 47. Kxg2 Qe2+ 48. Kh3 c4 49. a4 a5 50. Rf6\nKg8 51. Nh6+ Kg7 52. Rb6 Qe4 53. Kh2 Kh7 54. Rd6 Qe5 55. Nf7 Qxb2+ 56. Kh3\nQg7 0-1",
|
||||
},
|
||||
{
|
||||
id: "2013-carlsen-anand-05",
|
||||
matchSlug: "2013-carlsen-anand",
|
||||
matchLabel: "2013 — Carlsen vs Anand",
|
||||
matchYear: 2013,
|
||||
gameNumber: 5,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Anand, Viswanathan",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2013\"]\n[Site \"Chennai IND\"]\n[Date \"2013.11.15\"]\n[Round \"5\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Anand, Viswanathan\"]\n[Result \"1-0\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2870\"]\n[BlackElo \"2775\"]\n[ECO \"D31\"]\n[Opening \"QGD\"]\n[Variation \"semi-Slav, Marshall gambit\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"5000017\"]\n[EventDate \"2013.11.09\"]\n\n1. c4 e6 2. d4 d5 3. Nc3 c6 4. e4 dxe4 5. Nxe4 Bb4+ 6. Nc3 c5 7. a3 Ba5 8. Nf3\nNf6 9. Be3 Nc6 10. Qd3 cxd4 11. Nxd4 Ng4 12. O-O-O Nxe3 13. fxe3 Bc7 14. Nxc6\nbxc6 15. Qxd8+ Bxd8 16. Be2 Ke7 17. Bf3 Bd7 18. Ne4 Bb6 19. c5 f5 20. cxb6 fxe4\n21. b7 Rab8 22. Bxe4 Rxb7 23. Rhf1 Rb5 24. Rf4 g5 25. Rf3 h5 26. Rdf1 Be8 27.\nBc2 Rc5 28. Rf6 h4 29. e4 a5 30. Kd2 Rb5 31. b3 Bh5 32. Kc3 Rc5+ 33. Kb2 Rd8 34.\nR1f2 Rd4 35. Rh6 Bd1 36. Bb1 Rb5 37. Kc3 c5 38. Rb2 e5 39. Rg6 a4 40. Rxg5 Rxb3+\n41. Rxb3 Bxb3 42. Rxe5+ Kd6 43. Rh5 Rd1 44. e5+ Kd5 45. Bh7 Rc1+ 46. Kb2 Rg1 47.\nBg8+ Kc6 48. Rh6+ Kd7 49. Bxb3 axb3 50. Kxb3 Rxg2 51. Rxh4 Ke6 52. a4 Kxe5 53.\na5 Kd6 54. Rh7 Kd5 55. a6 c4+ 56. Kc3 Ra2 57. a7 Kc5 58. h4 1-0",
|
||||
},
|
||||
{
|
||||
id: "2013-carlsen-anand-06",
|
||||
matchSlug: "2013-carlsen-anand",
|
||||
matchLabel: "2013 — Carlsen vs Anand",
|
||||
matchYear: 2013,
|
||||
gameNumber: 6,
|
||||
white: "Anand, Viswanathan",
|
||||
black: "Carlsen, Magnus",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh 2013\"]\n[Site \"Chennai IND\"]\n[Date \"2013.11.16\"]\n[Round \"6\"]\n[White \"Anand, Viswanathan\"]\n[Black \"Carlsen, Magnus\"]\n[Result \"0-1\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2775\"]\n[BlackElo \"2870\"]\n[ECO \"C65\"]\n[Opening \"Ruy Lopez\"]\n[Variation \"Berlin defence\"]\n[WhiteFideId \"5000017\"]\n[BlackFideId \"1503014\"]\n[EventDate \"2013.11.09\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. d3 Bc5 5. c3 O-O 6. O-O Re8 7. Re1 a6 8. Ba4\nb5 9. Bb3 d6 10. Bg5 Be6 11. Nbd2 h6 12. Bh4 Bxb3 13. axb3 Nb8 14. h3 Nbd7 15.\nNh2 Qe7 16. Ndf1 Bb6 17. Ne3 Qe6 18. b4 a5 19. bxa5 Bxa5 20. Nhg4 Bb6 21. Bxf6\nNxf6 22. Nxf6+ Qxf6 23. Qg4 Bxe3 24. fxe3 Qe7 25. Rf1 c5 26. Kh2 c4 27. d4 Rxa1\n28. Rxa1 Qb7 29. Rd1 Qc6 30. Qf5 exd4 31. Rxd4 Re5 32. Qf3 Qc7 33. Kh1 Qe7 34.\nQg4 Kh7 35. Qf4 g6 36. Kh2 Kg7 37. Qf3 Re6 38. Qg3 Rxe4 39. Qxd6 Rxe3 40. Qxe7\nRxe7 41. Rd5 Rb7 42. Rd6 f6 43. h4 Kf7 44. h5 gxh5 45. Rd5 Kg6 46. Kg3 Rb6 47.\nRc5 f5 48. Kh4 Re6 49. Rxb5 Re4+ 50. Kh3 Kg5 51. Rb8 h4 52. Rg8+ Kh5 53. Rf8 Rf4\n54. Rc8 Rg4 55. Rf8 Rg3+ 56. Kh2 Kg5 57. Rg8+ Kf4 58. Rc8 Ke3 59. Rxc4 f4 60.\nRa4 h3 61. gxh3 Rg6 62. c4 f3 63. Ra3+ Ke2 64. b4 f2 65. Ra2+ Kf3 66. Ra3+ Kf4\n67. Ra8 Rg1 0-1",
|
||||
},
|
||||
{
|
||||
id: "2013-carlsen-anand-09",
|
||||
matchSlug: "2013-carlsen-anand",
|
||||
matchLabel: "2013 — Carlsen vs Anand",
|
||||
matchYear: 2013,
|
||||
gameNumber: 9,
|
||||
white: "Anand, Viswanathan",
|
||||
black: "Carlsen, Magnus",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh 2013\"]\n[Site \"Chennai IND\"]\n[Date \"2013.11.21\"]\n[Round \"9\"]\n[White \"Anand, Viswanathan\"]\n[Black \"Carlsen, Magnus\"]\n[Result \"0-1\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2775\"]\n[BlackElo \"2870\"]\n[ECO \"E25\"]\n[Opening \"Nimzo-Indian\"]\n[Variation \"Saemisch variation\"]\n[WhiteFideId \"5000017\"]\n[BlackFideId \"1503014\"]\n[EventDate \"2013.11.09\"]\n\n1. d4 Nf6 2. c4 e6 3. Nc3 Bb4 4. f3 d5 5. a3 Bxc3+ 6. bxc3 c5 7. cxd5 exd5 8. e3\nc4 9. Ne2 Nc6 10. g4 O-O 11. Bg2 Na5 12. O-O Nb3 13. Ra2 b5 14. Ng3 a5 15. g5\nNe8 16. e4 Nxc1 17. Qxc1 Ra6 18. e5 Nc7 19. f4 b4 20. axb4 axb4 21. Rxa6 Nxa6\n22. f5 b3 23. Qf4 Nc7 24. f6 g6 25. Qh4 Ne8 26. Qh6 b2 27. Rf4 b1=Q+ 28. Nf1 Qe1 0-1",
|
||||
},
|
||||
{
|
||||
id: "2014-carlsen-anand-02",
|
||||
matchSlug: "2014-carlsen-anand",
|
||||
matchLabel: "2014 — Carlsen vs Anand",
|
||||
matchYear: 2014,
|
||||
gameNumber: 2,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Anand, Viswanathan",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2014\"]\n[Site \"Sochi RUS\"]\n[Date \"2014.11.09\"]\n[Round \"2\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Anand, Viswanathan\"]\n[Result \"1-0\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2863\"]\n[BlackElo \"2792\"]\n[ECO \"C65\"]\n[Opening \"Ruy Lopez\"]\n[Variation \"Berlin defence\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"5000017\"]\n[EventDate \"2014.11.08\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. d3 Bc5 5. O-O d6 6. Re1 O-O 7. Bxc6 bxc6 8. h3\nRe8 9. Nbd2 Nd7 10. Nc4 Bb6 11. a4 a5 12. Nxb6 cxb6 13. d4 Qc7 14. Ra3 Nf8 15.\ndxe5 dxe5 16. Nh4 Rd8 17. Qh5 f6 18. Nf5 Be6 19. Rg3 Ng6 20. h4 Bxf5 21. exf5\nNf4 22. Bxf4 exf4 23. Rc3 c5 24. Re6 Rab8 25. Rc4 Qd7 26. Kh2 Rf8 27. Rce4 Rb7\n28. Qe2 b5 29. b3 bxa4 30. bxa4 Rb4 31. Re7 Qd6 32. Qf3 Rxe4 33. Qxe4 f3+ 34. g3\nh5 35. Qb7 1-0",
|
||||
},
|
||||
{
|
||||
id: "2014-carlsen-anand-06",
|
||||
matchSlug: "2014-carlsen-anand",
|
||||
matchLabel: "2014 — Carlsen vs Anand",
|
||||
matchYear: 2014,
|
||||
gameNumber: 6,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Anand, Viswanathan",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2014\"]\n[Site \"Sochi RUS\"]\n[Date \"2014.11.15\"]\n[Round \"6\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Anand, Viswanathan\"]\n[Result \"1-0\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2863\"]\n[BlackElo \"2792\"]\n[ECO \"B41\"]\n[Opening \"Sicilian\"]\n[Variation \"Kan, Maroczy bind (Reti variation)\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"5000017\"]\n[EventDate \"2014.11.08\"]\n\n1. e4 c5 2. Nf3 e6 3. d4 cxd4 4. Nxd4 a6 5. c4 Nf6 6. Nc3 Bb4 7. Qd3 Nc6 8. Nxc6\ndxc6 9. Qxd8+ Kxd8 10. e5 Nd7 11. Bf4 Bxc3+ 12. bxc3 Kc7 13. h4 b6 14. h5 h6 15.\nO-O-O Bb7 16. Rd3 c5 17. Rg3 Rag8 18. Bd3 Nf8 19. Be3 g6 20. hxg6 Nxg6 21. Rh5\nBc6 22. Bc2 Kb7 23. Rg4 a5 24. Bd1 Rd8 25. Bc2 Rdg8 26. Kd2 a4 27. Ke2 a3 28. f3\nRd8 29. Ke1 Rd7 30. Bc1 Ra8 31. Ke2 Ba4 32. Be4+ Bc6 33. Bxg6 fxg6 34. Rxg6 Ba4\n35. Rxe6 Rd1 36. Bxa3 Ra1 37. Ke3 Bc2 38. Re7+ 1-0",
|
||||
},
|
||||
{
|
||||
id: "2014-carlsen-anand-11",
|
||||
matchSlug: "2014-carlsen-anand",
|
||||
matchLabel: "2014 — Carlsen vs Anand",
|
||||
matchYear: 2014,
|
||||
gameNumber: 11,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Anand, Viswanathan",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2014\"]\n[Site \"Sochi RUS\"]\n[Date \"2014.11.23\"]\n[Round \"11\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Anand, Viswanathan\"]\n[Result \"1-0\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2863\"]\n[BlackElo \"2792\"]\n[ECO \"C67\"]\n[Opening \"Ruy Lopez\"]\n[Variation \"Berlin defence, open variation\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"5000017\"]\n[EventDate \"2014.11.08\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. O-O Nxe4 5. d4 Nd6 6. Bxc6 dxc6 7. dxe5 Nf5 8.\nQxd8+ Kxd8 9. h3 Bd7 10. Nc3 h6 11. b3 Kc8 12. Bb2 c5 13. Rad1 b6 14. Rfe1 Be6\n15. Nd5 g5 16. c4 Kb7 17. Kh2 a5 18. a4 Ne7 19. g4 Ng6 20. Kg3 Be7 21. Nd2 Rhd8\n22. Ne4 Bf8 23. Nef6 b5 24. Bc3 bxa4 25. bxa4 Kc6 26. Kf3 Rdb8 27. Ke4 Rb4 28.\nBxb4 cxb4 29. Nh5 Kb7 30. f4 gxf4 31. Nhxf4 Nxf4 32. Nxf4 Bxc4 33. Rd7 Ra6 34.\nNd5 Rc6 35. Rxf7 Bc5 36. Rxc7+ Rxc7 37. Nxc7 Kc6 38. Nb5 Bxb5 39. axb5+ Kxb5 40.\ne6 b3 41. Kd3 Be7 42. h4 a4 43. g5 hxg5 44. hxg5 a3 45. Kc3 1-0",
|
||||
},
|
||||
{
|
||||
id: "2016-carlsen-karjakin-08",
|
||||
matchSlug: "2016-carlsen-karjakin",
|
||||
matchLabel: "2016 — Carlsen vs Karjakin",
|
||||
matchYear: 2016,
|
||||
gameNumber: 8,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Karjakin, Sergey",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh 2016\"]\n[Site \"New York USA\"]\n[Date \"2016.11.21\"]\n[Round \"8\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Karjakin, Sergey\"]\n[Result \"0-1\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2853\"]\n[BlackElo \"2772\"]\n[ECO \"D05\"]\n[Opening \"Queen's pawn game, Rubinstein (Colle-Zukertort) variation\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"14109603\"]\n[EventDate \"2016.11.11\"]\n\n1. d4 Nf6 2. Nf3 d5 3. e3 e6 4. Bd3 c5 5. b3 Be7 6. O-O O-O 7. Bb2 b6 8. dxc5\nBxc5 9. Nbd2 Bb7 10. Qe2 Nbd7 11. c4 dxc4 12. Nxc4 Qe7 13. a3 a5 14. Nd4 Rfd8\n15. Rfd1 Rac8 16. Rac1 Nf8 17. Qe1 Ng6 18. Bf1 Ng4 19. Nb5 Bc6 20. a4 Bd5 21.\nBd4 Bxc4 22. Rxc4 Bxd4 23. Rdxd4 Rxc4 24. bxc4 Nf6 25. Qd2 Rb8 26. g3 Ne5 27.\nBg2 h6 28. f4 Ned7 29. Na7 Qa3 30. Nc6 Rf8 31. h3 Nc5 32. Kh2 Nxa4 33. Rd8 g6\n34. Qd4 Kg7 35. c5 Rxd8 36. Nxd8 Nxc5 37. Qd6 Qd3 38. Nxe6+ fxe6 39. Qe7+ Kg8\n40. Qxf6 a4 41. e4 Qd7 42. Qxg6+ Qg7 43. Qe8+ Qf8 44. Qc6 Qd8 45. f5 a3 46. fxe6\nKg7 47. e7 Qxe7 48. Qxb6 Nd3 49. Qa5 Qc5 50. Qa6 Ne5 51. Qe6 h5 52. h4 a2 0-1",
|
||||
},
|
||||
{
|
||||
id: "2016-carlsen-karjakin-10",
|
||||
matchSlug: "2016-carlsen-karjakin",
|
||||
matchLabel: "2016 — Carlsen vs Karjakin",
|
||||
matchYear: 2016,
|
||||
gameNumber: 10,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Karjakin, Sergey",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2016\"]\n[Site \"New York USA\"]\n[Date \"2016.11.24\"]\n[Round \"10\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Karjakin, Sergey\"]\n[Result \"1-0\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2853\"]\n[BlackElo \"2772\"]\n[ECO \"C65\"]\n[Opening \"Ruy Lopez\"]\n[Variation \"Berlin defence\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"14109603\"]\n[EventDate \"2016.11.11\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. d3 Bc5 5. c3 O-O 6. Bg5 h6 7. Bh4 Be7 8. O-O\nd6 9. Nbd2 Nh5 10. Bxe7 Qxe7 11. Nc4 Nf4 12. Ne3 Qf6 13. g3 Nh3+ 14. Kh1 Ne7 15.\nBc4 c6 16. Bb3 Ng6 17. Qe2 a5 18. a4 Be6 19. Bxe6 fxe6 20. Nd2 d5 21. Qh5 Ng5\n22. h4 Nf3 23. Nxf3 Qxf3+ 24. Qxf3 Rxf3 25. Kg2 Rf7 26. Rfe1 h5 27. Nf1 Kf8 28.\nNd2 Ke7 29. Re2 Kd6 30. Nf3 Raf8 31. Ng5 Re7 32. Rae1 Rfe8 33. Nf3 Nh8 34. d4\nexd4 35. Nxd4 g6 36. Re3 Nf7 37. e5+ Kd7 38. Rf3 Nh6 39. Rf6 Rg7 40. b4 axb4 41.\ncxb4 Ng8 42. Rf3 Nh6 43. a5 Nf5 44. Nb3 Kc7 45. Nc5 Kb8 46. Rb1 Ka7 47. Rd3 Rc7\n48. Ra3 Nd4 49. Rd1 Nf5 50. Kh3 Nh6 51. f3 Rf7 52. Rd4 Nf5 53. Rd2 Rh7 54. Rb3\nRee7 55. Rdd3 Rh8 56. Rb1 Rhh7 57. b5 cxb5 58. Rxb5 d4 59. Rb6 Rc7 60. Nxe6 Rc3\n61. Nf4 Rhc7 62. Nd5 Rxd3 63. Nxc7 Kb8 64. Nb5 Kc8 65. Rxg6 Rxf3 66. Kg2 Rb3 67.\nNd6+ Nxd6 68. Rxd6 Re3 69. e6 Kc7 70. Rxd4 Rxe6 71. Rd5 Rh6 72. Kf3 Kb8 73. Kf4\nKa7 74. Kg5 Rh8 75. Kf6 1-0",
|
||||
},
|
||||
{
|
||||
id: "2018-carlsen-caruana-01",
|
||||
matchSlug: "2018-carlsen-caruana",
|
||||
matchLabel: "2018 — Carlsen vs Caruana",
|
||||
matchYear: 2018,
|
||||
gameNumber: 1,
|
||||
white: "Caruana, Fabiano",
|
||||
black: "Carlsen, Magnus",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"WCh 2018\"]\n[Site \"London ENG\"]\n[Date \"2018.11.09\"]\n[Round \"1\"]\n[White \"Caruana, Fabiano\"]\n[Black \"Carlsen, Magnus\"]\n[Result \"1/2-1/2\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2832\"]\n[BlackElo \"2835\"]\n[ECO \"B31\"]\n[Opening \"Sicilian\"]\n[Variation \"Nimzovich-Rossolimo attack (with ...g6, without ...d6)\"]\n[WhiteFideId \"2020009\"]\n[BlackFideId \"1503014\"]\n[EventDate \"2018.11.09\"]\n1. e4 c5 2. Nf3 Nc6 3. Bb5 g6 4. Bxc6 dxc6 5. d3 Bg7 6. h3 Nf6 7. Nc3 Nd7 8. Be3\ne5 9. O-O b6 10. Nh2 Nf8 11. f4 exf4 12. Rxf4 Be6 13. Rf2 h6 14. Qd2 g5 15. Raf1\nQd6 16. Ng4 O-O-O 17. Nf6 Nd7 18. Nh5 Be5 19. g4 f6 20. b3 Bf7 21. Nd1 Nf8 22.\nNxf6 Ne6 23. Nh5 Bxh5 24. gxh5 Nf4 25. Bxf4 gxf4 26. Rg2 Rhg8 27. Qe2 Rxg2+ 28.\nQxg2 Qe6 29. Nf2 Rg8 30. Ng4 Qe8 31. Qf3 Qxh5 32. Kf2 Bc7 33. Ke2 Qg5 34. Nh2 h5\n35. Rf2 Qg1 36. Nf1 h4 37. Kd2 Kb7 38. c3 Be5 39. Kc2 Qg7 40. Nh2 Bxc3 41. Qxf4\nBd4 42. Qf7+ Ka6 43. Qxg7 Rxg7 44. Re2 Rg3 45. Ng4 Rxh3 46. e5 Rf3 47. e6 Rf8\n48. e7 Re8 49. Nh6 h3 50. Nf5 Bf6 51. a3 b5 52. b4 cxb4 53. axb4 Bxe7 54. Nxe7\nh2 55. Rxh2 Rxe7 56. Rh6 Kb6 57. Kc3 Rd7 58. Rg6 Kc7 59. Rh6 Rd6 60. Rh8 Rg6 61.\nRa8 Kb7 62. Rh8 Rg5 63. Rh7+ Kb6 64. Rh6 Rg1 65. Kc2 Rf1 66. Rg6 Rh1 67. Rf6 Rh8\n68. Kc3 Ra8 69. d4 Rd8 70. Rh6 Rd7 71. Rg6 Kc7 72. Rg5 Rd6 73. Rg8 Rh6 74. Ra8\nRh3+ 75. Kc2 Ra3 76. Kb2 Ra4 77. Kc3 a6 78. Rh8 Ra3+ 79. Kb2 Rg3 80. Kc2 Rg5 81.\nRh6 Rd5 82. Kc3 Rd6 83. Rh8 Rg6 84. Kc2 Kb7 85. Kc3 Rg3+ 86. Kc2 Rg1 87. Rh5\nRg2+ 88. Kc3 Rg3+ 89. Kc2 Rg4 90. Kc3 Kb6 91. Rh6 Rg5 92. Rf6 Rh5 93. Rg6 Rh3+\n94. Kc2 Rh5 95. Kc3 Rd5 96. Rh6 Kc7 97. Rh7+ Rd7 98. Rh5 Rd6 99. Rh8 Rg6 100.\nRf8 Rg3+ 101. Kc2 Ra3 102. Rf7+ Kd6 103. Ra7 Kd5 104. Kb2 Rd3 105. Rxa6 Rxd4\n106. Kb3 Re4 107. Kc3 Rc4+ 108. Kb3 Kd4 109. Rb6 Kd3 110. Ra6 Rc2 111. Rb6 Rc3+\n112. Kb2 Rc4 113. Kb3 Kd4 114. Ra6 Kd5 115. Ra8 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "2018-carlsen-caruana-06",
|
||||
matchSlug: "2018-carlsen-caruana",
|
||||
matchLabel: "2018 — Carlsen vs Caruana",
|
||||
matchYear: 2018,
|
||||
gameNumber: 6,
|
||||
white: "Carlsen, Magnus",
|
||||
black: "Caruana, Fabiano",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"WCh 2018\"]\n[Site \"London ENG\"]\n[Date \"2018.11.16\"]\n[Round \"6\"]\n[White \"Carlsen, Magnus\"]\n[Black \"Caruana, Fabiano\"]\n[Result \"1/2-1/2\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2835\"]\n[BlackElo \"2832\"]\n[ECO \"C42\"]\n[Opening \"Petrov's defence\"]\n[WhiteFideId \"1503014\"]\n[BlackFideId \"2020009\"]\n[EventDate \"2018.11.09\"]\n1. e4 e5 2. Nf3 Nf6 3. Nxe5 d6 4. Nd3 Nxe4 5. Qe2 Qe7 6. Nf4 Nc6 7. Nd5 Nd4 8.\nNxe7 Nxe2 9. Nd5 Nd4 10. Na3 Ne6 11. f3 N4c5 12. d4 Nd7 13. c3 c6 14. Nf4 Nb6\n15. Bd3 d5 16. Nc2 Bd6 17. Nxe6 Bxe6 18. Kf2 h5 19. h4 Nc8 20. Ne3 Ne7 21. g3 c5\n22. Bc2 O-O 23. Rd1 Rfd8 24. Ng2 cxd4 25. cxd4 Rac8 26. Bb3 Nc6 27. Bf4 Na5 28.\nRdc1 Bb4 29. Bd1 Nc4 30. b3 Na3 31. Rxc8 Rxc8 32. Rc1 Nb5 33. Rxc8+ Bxc8 34. Ne3\nNc3 35. Bc2 Ba3 36. Bb8 a6 37. f4 Bd7 38. f5 Bc6 39. Bd1 Bb2 40. Bxh5 Ne4+ 41.\nKg2 Bxd4 42. Bf4 Bc5 43. Bf3 Nd2 44. Bxd5 Bxe3 45. Bxc6 Bxf4 46. Bxb7 Bd6 47.\nBxa6 Ne4 48. g4 Ba3 49. Bc4 Kf8 50. g5 Nc3 51. b4 Bxb4 52. Kf3 Na4 53. Bb5 Nc5\n54. a4 f6 55. Kg4 Ne4 56. Kh5 Be1 57. Bd3 Nd6 58. a5 Bxa5 59. gxf6 gxf6 60. Kg6\nBd8 61. Kh7 Nf7 62. Bc4 Ne5 63. Bd5 Ba5 64. h5 Bd2 65. Ba2 Nf3 66. Bd5 Nd4 67.\nKg6 Bg5 68. Bc4 Nf3 69. Kh7 Ne5 70. Bb3 Ng4 71. Bc4 Ne3 72. Bd3 Ng4 73. Bc4 Nh6\n74. Kg6 Ke7 75. Bb3 Kd6 76. Bc2 Ke5 77. Bd3 Kf4 78. Bc2 Ng4 79. Bb3 Ne3 80. h6\nBxh6 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "2018-carlsen-caruana-12",
|
||||
matchSlug: "2018-carlsen-caruana",
|
||||
matchLabel: "2018 — Carlsen vs Caruana",
|
||||
matchYear: 2018,
|
||||
gameNumber: 12,
|
||||
white: "Caruana, Fabiano",
|
||||
black: "Carlsen, Magnus",
|
||||
result: "1/2-1/2",
|
||||
pgn: "[Event \"WCh 2018\"]\n[Site \"London ENG\"]\n[Date \"2018.11.26\"]\n[Round \"12\"]\n[White \"Caruana, Fabiano\"]\n[Black \"Carlsen, Magnus\"]\n[Result \"1/2-1/2\"]\n[WhiteTitle \"GM\"]\n[BlackTitle \"GM\"]\n[WhiteElo \"2832\"]\n[BlackElo \"2835\"]\n[ECO \"B33\"]\n[Opening \"Sicilian\"]\n[Variation \"Pelikan (Lasker/Sveshnikov) variation\"]\n[WhiteFideId \"2020009\"]\n[BlackFideId \"1503014\"]\n[EventDate \"2018.11.09\"]\n1. e4 c5 2. Nf3 Nc6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 e5 6. Ndb5 d6 7. Nd5 Nxd5 8.\nexd5 Ne7 9. c4 Ng6 10. Qa4 Bd7 11. Qb4 Bf5 12. h4 h5 13. Qa4 Bd7 14. Qb4 Bf5 15.\nBe3 a6 16. Nc3 Qc7 17. g3 Be7 18. f3 Nf8 19. Ne4 Nd7 20. Bd3 O-O 21. Rh2 Rac8\n22. O-O-O Bg6 23. Rc2 f5 24. Nf2 Nc5 25. f4 a5 26. Qd2 e4 27. Be2 Be8 28. Kb1\nBf6 29. Re1 a4 30. Qb4 g6 31. Rd1 Ra8 1/2-1/2",
|
||||
},
|
||||
{
|
||||
id: "2021-carlsen-nepomniachtchi-06",
|
||||
matchSlug: "2021-carlsen-nepomniachtchi",
|
||||
matchLabel: "2021 — Carlsen vs Nepomniachtchi",
|
||||
matchYear: 2021,
|
||||
gameNumber: 6,
|
||||
white: "Carlsen,M",
|
||||
black: "Nepomniachtchi,I",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2021\"]\n[Site \"Dubai UAE\"]\n[Date \"2021.12.03\"]\n[Round \"6\"]\n[White \"Carlsen,M\"]\n[Black \"Nepomniachtchi,I\"]\n[Result \"1-0\"]\n[WhiteElo \"2855\"]\n[BlackElo \"2782\"]\n[ECO \"D02\"]\n\n1.d4 Nf6 2.Nf3 d5 3.g3 e6 4.Bg2 Be7 5.O-O O-O 6.b3 c5 7.dxc5 Bxc5 8.c4 dxc4\n9.Qc2 Qe7 10.Nbd2 Nc6 11.Nxc4 b5 12.Nce5 Nb4 13.Qb2 Bb7 14.a3 Nc6 15.Nd3 Bb6\n16.Bg5 Rfd8 17.Bxf6 gxf6 18.Rac1 Nd4 19.Nxd4 Bxd4 20.Qa2 Bxg2 21.Kxg2 Qb7+\n22.Kg1 Qe4 23.Qc2 a5 24.Rfd1 Kg7 25.Rd2 Rac8 26.Qxc8 Rxc8 27.Rxc8 Qd5 28.b4 a4\n29.e3 Be5 30.h4 h5 31.Kh2 Bb2 32.Rc5 Qd6 33.Rd1 Bxa3 34.Rxb5 Qd7 35.Rc5 e5\n36.Rc2 Qd5 37.Rdd2 Qb3 38.Ra2 e4 39.Nc5 Qxb4 40.Nxe4 Qb3 41.Rac2 Bf8 42.Nc5 Qb5\n43.Nd3 a3 44.Nf4 Qa5 45.Ra2 Bb4 46.Rd3 Kh6 47.Rd1 Qa4 48.Rda1 Bd6 49.Kg1 Qb3\n50.Ne2 Qd3 51.Nd4 Kh7 52.Kh2 Qe4 53.Rxa3 Qxh4+ 54.Kg1 Qe4 55.Ra4 Be5 56.Ne2 Qc2\n57.R1a2 Qb3 58.Kg2 Qd5+ 59.f3 Qd1 60.f4 Bc7 61.Kf2 Bb6 62.Ra1 Qb3 63.Re4 Kg7\n64.Re8 f5 65.Raa8 Qb4 66.Rac8 Ba5 67.Rc1 Bb6 68.Re5 Qb3 69.Re8 Qd5 70.Rcc8 Qh1\n71.Rc1 Qd5 72.Rb1 Ba7 73.Re7 Bc5 74.Re5 Qd3 75.Rb7 Qc2 76.Rb5 Ba7 77.Ra5 Bb6\n78.Rab5 Ba7 79.Rxf5 Qd3 80.Rxf7+ Kxf7 81.Rb7+ Kg6 82.Rxa7 Qd5 83.Ra6+ Kh7\n84.Ra1 Kg6 85.Nd4 Qb7 86.Ra2 Qh1 87.Ra6+ Kf7 88.Nf3 Qb1 89.Rd6 Kg7 90.Rd5 Qa2+\n91.Rd2 Qb1 92.Re2 Qb6 93.Rc2 Qb1 94.Nd4 Qh1 95.Rc7+ Kf6 96.Rc6+ Kf7 97.Nf3 Qb1\n98.Ng5+ Kg7 99.Ne6+ Kf7 100.Nd4 Qh1 101.Rc7+ Kf6 102.Nf3 Qb1 103.Rd7 Qb2+\n104.Rd2 Qb1 105.Ng1 Qb4 106.Rd1 Qb3 107.Rd6+ Kg7 108.Rd4 Qb2+ 109.Ne2 Qb1\n110.e4 Qh1 111.Rd7+ Kg8 112.Rd4 Qh2+ 113.Ke3 h4 114.gxh4 Qh3+ 115.Kd2 Qxh4\n116.Rd3 Kf8 117.Rf3 Qd8+ 118.Ke3 Qa5 119.Kf2 Qa7+ 120.Re3 Qd7 121.Ng3 Qd2+\n122.Kf3 Qd1+ 123.Re2 Qb3+ 124.Kg2 Qb7 125.Rd2 Qb3 126.Rd5 Ke7 127.Re5+ Kf7\n128.Rf5+ Ke8 129.e5 Qa2+ 130.Kh3 Qe6 131.Kh4 Qh6+ 132.Nh5 Qh7 133.e6 Qg6\n134.Rf7 Kd8 135.f5 Qg1 136.Ng7 1-0",
|
||||
},
|
||||
{
|
||||
id: "2021-carlsen-nepomniachtchi-08",
|
||||
matchSlug: "2021-carlsen-nepomniachtchi",
|
||||
matchLabel: "2021 — Carlsen vs Nepomniachtchi",
|
||||
matchYear: 2021,
|
||||
gameNumber: 8,
|
||||
white: "Carlsen,M",
|
||||
black: "Nepomniachtchi,I",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2021\"]\n[Site \"Dubai UAE\"]\n[Date \"2021.12.05\"]\n[Round \"8\"]\n[White \"Carlsen,M\"]\n[Black \"Nepomniachtchi,I\"]\n[Result \"1-0\"]\n[WhiteElo \"2855\"]\n[BlackElo \"2782\"]\n[ECO \"C43\"]\n\n1.e4 e5 2.Nf3 Nf6 3.d4 Nxe4 4.Bd3 d5 5.Nxe5 Nd7 6.Nxd7 Bxd7 7.Nd2 Nxd2 8.Bxd2 Bd6\n9.O-O h5 10.Qe1+ Kf8 11.Bb4 Qe7 12.Bxd6 Qxd6 13.Qd2 Re8 14.Rae1 Rh6 15.Qg5 c6\n16.Rxe8+ Bxe8 17.Re1 Qf6 18.Qe3 Bd7 19.h3 h4 20.c4 dxc4 21.Bxc4 b5 22.Qa3+ Kg8\n23.Qxa7 Qd8 24.Bb3 Rd6 25.Re4 Be6 26.Bxe6 Rxe6 27.Rxe6 fxe6 28.Qc5 Qa5 29.Qxc6 Qe1+\n30.Kh2 Qxf2 31.Qxe6+ Kh7 32.Qe4+ Kg8 33.b3 Qxa2 34.Qe8+ Kh7 35.Qxb5 Qf2 36.Qe5 Qb2\n37.Qe4+ Kg8 38.Qd3 Qf2 39.Qc3 Qf4+ 40.Kg1 Kh7 41.Qd3+ g6 42.Qd1 Qe3+ 43.Kh1 g5\n44.d5 g4 45.hxg4 h3 46.Qf3 1-0",
|
||||
},
|
||||
{
|
||||
id: "2021-carlsen-nepomniachtchi-09",
|
||||
matchSlug: "2021-carlsen-nepomniachtchi",
|
||||
matchLabel: "2021 — Carlsen vs Nepomniachtchi",
|
||||
matchYear: 2021,
|
||||
gameNumber: 9,
|
||||
white: "Nepomniachtchi,I",
|
||||
black: "Carlsen,M",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh 2021\"]\n[Site \"Dubai UAE\"]\n[Date \"2021.12.07\"]\n[Round \"9\"]\n[White \"Nepomniachtchi,I\"]\n[Black \"Carlsen,M\"]\n[Result \"0-1\"]\n[WhiteElo \"2782\"]\n[BlackElo \"2855\"]\n[ECO \"A13\"]\n\n1.c4 e6 2.g3 d5 3.Bg2 d4 4.Nf3 Nc6 5.O-O Bc5 6.d3 Nf6 7.Nbd2 a5 8.Nb3 Be7\n9.e3 dxe3 10.Bxe3 Ng4 11.Bc5 O-O 12.d4 a4 13.Bxe7 Qxe7 14.Nc5 a3 15.bxa3 Rd8\n16.Nb3 Nf6 17.Re1 Qxa3 18.Qe2 h6 19.h4 Bd7 20.Ne5 Be8 21.Qe3 Qb4 22.Reb1 Nxe5\n23.dxe5 Ng4 24.Qe1 Qxe1+ 25.Rxe1 h5 26.Bxb7 Ra4 27.c5 c6 28.f3 Nh6 29.Re4 Ra7\n30.Rb4 Rb8 31.a4 Raxb7 32.Rb6 Rxb6 33.cxb6 Rxb6 34.Nc5 Nf5 35.a5 Rb8 36.a6 Nxg3\n37.Na4 c5 38.a7 Rd8 39.Nxc5 Ra8 0-1",
|
||||
},
|
||||
{
|
||||
id: "2023-ding-nepomniachtchi-04",
|
||||
matchSlug: "2023-ding-nepomniachtchi",
|
||||
matchLabel: "2023 — Ding vs Nepomniachtchi",
|
||||
matchYear: 2023,
|
||||
gameNumber: 4,
|
||||
white: "Ding Liren",
|
||||
black: "Nepomniachtchi,I",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2023\"]\n[Site \"Astana KAZ\"]\n[Date \"2023.04.13\"]\n[Round \"4.1\"]\n[White \"Ding Liren\"]\n[Black \"Nepomniachtchi,I\"]\n[Result \"1-0\"]\n[WhiteElo \"2788\"]\n[BlackElo \"2795\"]\n[ECO \"A28\"]\n\n1.c4 Nf6 2.Nc3 e5 3.Nf3 Nc6 4.e3 Bb4 5.Qc2 Bxc3 6.bxc3 d6 7.e4 O-O 8.Be2 Nh5\n9.d4 Nf4 10.Bxf4 exf4 11.O-O Qf6 12.Rfe1 Re8 13.Bd3 Bg4 14.Nd2 Na5 15.c5 dxc5\n16.e5 Qh6 17.d5 Rad8 18.c4 b6 19.h3 Bh5 20.Be4 Re7 21.Qc3 Rde8 22.Bf3 Nb7\n23.Re2 f6 24.e6 Nd6 25.Rae1 Nf5 26.Bxh5 Qxh5 27.Re4 Qh6 28.Qf3 Nd4 29.Rxd4 cxd4\n30.Nb3 g5 31.Nxd4 Qg6 32.g4 fxg3 33.fxg3 h5 34.Nf5 Rh7 35.Qe4 Kh8 36.e7 Qf7\n37.d6 cxd6 38.Nxd6 Qg8 39.Nxe8 Qxe8 40.Qe6 Kg7 41.Rf1 Rh6 42.Rd1 f5 43.Qe5+ Kf7\n44.Qxf5+ Rf6 45.Qh7+ Ke6 46.Qg7 Rg6 47.Qf8 1-0",
|
||||
},
|
||||
{
|
||||
id: "2023-ding-nepomniachtchi-06",
|
||||
matchSlug: "2023-ding-nepomniachtchi",
|
||||
matchLabel: "2023 — Ding vs Nepomniachtchi",
|
||||
matchYear: 2023,
|
||||
gameNumber: 6,
|
||||
white: "Ding Liren",
|
||||
black: "Nepomniachtchi,I",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2023\"]\n[Site \"Astana KAZ\"]\n[Date \"2023.04.16\"]\n[Round \"6.1\"]\n[White \"Ding Liren\"]\n[Black \"Nepomniachtchi,I\"]\n[Result \"1-0\"]\n[WhiteElo \"2788\"]\n[BlackElo \"2795\"]\n[ECO \"D02\"]\n\n1.d4 Nf6 2.Nf3 d5 3.Bf4 c5 4.e3 Nc6 5.Nbd2 cxd4 6.exd4 Bf5 7.c3 e6 8.Bb5 Bd6\n9.Bxd6 Qxd6 10.O-O O-O 11.Re1 h6 12.Ne5 Ne7 13.a4 a6 14.Bf1 Nd7 15.Nxd7 Qxd7\n16.a5 Qc7 17.Qf3 Rfc8 18.Ra3 Bg6 19.Nb3 Nc6 20.Qg3 Qe7 21.h4 Re8 22.Nc5 e5\n23.Rb3 Nxa5 24.Rxe5 Qf6 25.Ra3 Nc4 26.Bxc4 dxc4 27.h5 Bc2 28.Nxb7 Qb6 29.Nd6 Rxe5\n30.Qxe5 Qxb2 31.Ra5 Kh7 32.Rc5 Qc1+ 33.Kh2 f6 34.Qg3 a5 35.Nxc4 a4 36.Ne3 Bb1\n37.Rc7 Rg8 38.Nd5 Kh8 39.Ra7 a3 40.Ne7 Rf8 41.d5 a2 42.Qc7 Kh7 43.Ng6 Rg8\n44.Qf7 1-0",
|
||||
},
|
||||
{
|
||||
id: "2023-ding-nepomniachtchi-12",
|
||||
matchSlug: "2023-ding-nepomniachtchi",
|
||||
matchLabel: "2023 — Ding vs Nepomniachtchi",
|
||||
matchYear: 2023,
|
||||
gameNumber: 12,
|
||||
white: "Ding Liren",
|
||||
black: "Nepomniachtchi,I",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2023\"]\n[Site \"Astana KAZ\"]\n[Date \"2023.04.26\"]\n[Round \"12.1\"]\n[White \"Ding Liren\"]\n[Black \"Nepomniachtchi,I\"]\n[Result \"1-0\"]\n[WhiteElo \"2788\"]\n[BlackElo \"2795\"]\n[ECO \"D04\"]\n\n1.d4 Nf6 2.Nf3 d5 3.e3 c5 4.Nbd2 cxd4 5.exd4 Qc7 6.c3 Bd7 7.Bd3 Nc6 8.O-O Bg4\n9.Re1 e6 10.Nf1 Bd6 11.Bg5 O-O 12.Bxf6 gxf6 13.Ng3 f5 14.h3 Bxf3 15.Qxf3 Ne7\n16.Nh5 Kh8 17.g4 Rg8 18.Kh1 Ng6 19.Bc2 Nh4 20.Qe3 Rg6 21.Rg1 f4 22.Qd3 Qe7\n23.Rae1 Qg5 24.c4 dxc4 25.Qc3 b5 26.a4 b4 27.Qxc4 Rag8 28.Qc6 Bb8 29.Qb7 Rh6\n30.Be4 Rf8 31.Qxb4 Qd8 32.Qc3 Ng6 33.Bg2 Qh4 34.Re2 f5 35.Rxe6 Rxh5 36.gxh5 Qxh5\n37.d5+ Kg8 38.d6 1-0",
|
||||
},
|
||||
{
|
||||
id: "2024-gukesh-ding-03",
|
||||
matchSlug: "2024-gukesh-ding",
|
||||
matchLabel: "2024 — Gukesh vs Ding",
|
||||
matchYear: 2024,
|
||||
gameNumber: 3,
|
||||
white: "Gukesh,D",
|
||||
black: "Ding Liren",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2024\"]\n[Site \"Singapore SIN\"]\n[Date \"2024.11.27\"]\n[Round \"3.1\"]\n[White \"Gukesh,D\"]\n[Black \"Ding Liren\"]\n[Result \"1-0\"]\n[WhiteElo \"2783\"]\n[BlackElo \"2728\"]\n[ECO \"D02\"]\n\n1.d4 Nf6 2.Nf3 d5 3.c4 e6 4.cxd5 exd5 5.Nc3 c6 6.Qc2 g6 7.h3 Bf5 8.Qb3 Qb6\n9.g4 Qxb3 10.axb3 Bc2 11.Bf4 h5 12.Rg1 hxg4 13.hxg4 Nbd7 14.Nd2 Rg8 15.g5 Nh5\n16.Bh2 Rh8 17.f3 Ng7 18.Bg3 Rh5 19.e4 dxe4 20.fxe4 Ne6 21.Rc1 Nxd4 22.Bf2 Bg7\n23.Ne2 Nxb3 24.Rxc2 Nxd2 25.Kxd2 Ne5 26.Nd4 Rd8 27.Ke2 Rh2 28.Bg2 a6 29.b3 Rd7\n30.Rcc1 Ke7 31.Rcd1 Ke8 32.Bg3 Rh5 33.Nf3 Nxf3 34.Kxf3 Bd4 35.Rh1 Rxg5 36.Bh3 f5\n37.Bf4 Rh5 1-0",
|
||||
},
|
||||
{
|
||||
id: "2024-gukesh-ding-11",
|
||||
matchSlug: "2024-gukesh-ding",
|
||||
matchLabel: "2024 — Gukesh vs Ding",
|
||||
matchYear: 2024,
|
||||
gameNumber: 11,
|
||||
white: "Gukesh,D",
|
||||
black: "Ding Liren",
|
||||
result: "1-0",
|
||||
pgn: "[Event \"WCh 2024\"]\n[Site \"Singapore SIN\"]\n[Date \"2024.12.08\"]\n[Round \"11.1\"]\n[White \"Gukesh,D\"]\n[Black \"Ding Liren\"]\n[Result \"1-0\"]\n[WhiteElo \"2783\"]\n[BlackElo \"2728\"]\n[ECO \"A09\"]\n\n1.Nf3 d5 2.c4 d4 3.b4 c5 4.e3 Nf6 5.a3 Bg4 6.exd4 cxd4 7.h3 Bxf3 8.Qxf3 Qc7\n9.d3 a5 10.b5 Nbd7 11.g3 Nc5 12.Bg2 Nfd7 13.O-O Ne5 14.Qf4 Rd8 15.Rd1 g6\n16.a4 h5 17.b6 Qd6 18.Ba3 Bh6 19.Bxc5 Qxc5 20.Qe4 Nc6 21.Na3 Rd7 22.Nc2 Qxb6\n23.Rab1 Qc7 24.Rb5 O-O 25.Na1 Rb8 26.Nb3 e6 27.Nc5 Re7 28.Rdb1 Qc8 29.Qxc6 1-0",
|
||||
},
|
||||
{
|
||||
id: "2024-gukesh-ding-14",
|
||||
matchSlug: "2024-gukesh-ding",
|
||||
matchLabel: "2024 — Gukesh vs Ding",
|
||||
matchYear: 2024,
|
||||
gameNumber: 14,
|
||||
white: "Ding Liren",
|
||||
black: "Gukesh,D",
|
||||
result: "0-1",
|
||||
pgn: "[Event \"WCh 2024\"]\n[Site \"Singapore SIN\"]\n[Date \"2024.12.12\"]\n[Round \"14.1\"]\n[White \"Ding Liren\"]\n[Black \"Gukesh,D\"]\n[Result \"0-1\"]\n[WhiteElo \"2728\"]\n[BlackElo \"2783\"]\n[ECO \"A08\"]\n\n1.Nf3 d5 2.g3 c5 3.Bg2 Nc6 4.d4 e6 5.O-O cxd4 6.Nxd4 Nge7 7.c4 Nxd4 8.Qxd4 Nc6\n9.Qd1 d4 10.e3 Bc5 11.exd4 Bxd4 12.Nc3 O-O 13.Nb5 Bb6 14.b3 a6 15.Nc3 Bd4\n16.Bb2 e5 17.Qd2 Be6 18.Nd5 b5 19.cxb5 axb5 20.Nf4 exf4 21.Bxc6 Bxb2 22.Qxb2 Rb8\n23.Rfd1 Qb6 24.Bf3 fxg3 25.hxg3 b4 26.a4 bxa3 27.Rxa3 g6 28.Qd4 Qb5 29.b4 Qxb4\n30.Qxb4 Rxb4 31.Ra8 Rxa8 32.Bxa8 g5 33.Bd5 Bf5 34.Rc1 Kg7 35.Rc7 Bg6 36.Rc4 Rb1+\n37.Kg2 Re1 38.Rb4 h5 39.Ra4 Re5 40.Bf3 Kh6 41.Kg1 Re6 42.Rc4 g4 43.Bd5 Rd6\n44.Bb7 Kg5 45.f3 f5 46.fxg4 hxg4 47.Rb4 Bf7 48.Kf2 Rd2+ 49.Kg1 Kf6 50.Rb6+ Kg5\n51.Rb4 Be6 52.Ra4 Rb2 53.Ba8 Kf6 54.Rf4 Ke5 55.Rf2 Rxf2 56.Kxf2 Bd5 57.Bxd5 Kxd5\n58.Ke3 Ke5 0-1",
|
||||
},
|
||||
];
|
||||
35
src/chess/wcc-games.ts
Normal file
35
src/chess/wcc-games.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Public API over the auto-generated WCC game catalog
|
||||
* ({@link WCC_GAMES} / {@link WCC_MATCHES} from `wcc-data.generated.ts`).
|
||||
*
|
||||
* The picker UI uses {@link listWccMatches} / {@link listWccGamesForMatch};
|
||||
* the engine uses {@link findWccGame} to resolve a slug into a PGN.
|
||||
*/
|
||||
|
||||
import {
|
||||
WCC_GAMES,
|
||||
WCC_MATCHES,
|
||||
type WccGameData,
|
||||
type WccMatchData,
|
||||
} from "./wcc-data.generated";
|
||||
|
||||
export type { WccGameData, WccMatchData };
|
||||
|
||||
/** All matches that have at least one bundled game, sorted newest-first. */
|
||||
export function listWccMatches(): WccMatchData[] {
|
||||
return [...WCC_MATCHES].sort((a, b) => b.matchYear - a.matchYear);
|
||||
}
|
||||
|
||||
/** All bundled games for a given matchSlug, sorted by game number ascending. */
|
||||
export function listWccGamesForMatch(matchSlug: string): WccGameData[] {
|
||||
return WCC_GAMES.filter((g) => g.matchSlug === matchSlug).sort(
|
||||
(a, b) => a.gameNumber - b.gameNumber
|
||||
);
|
||||
}
|
||||
|
||||
/** Look up a single game by id (slug). Case-insensitive. */
|
||||
export function findWccGame(idOrName: string | undefined): WccGameData | null {
|
||||
if (!idOrName) return null;
|
||||
const target = idOrName.trim().toLowerCase();
|
||||
return WCC_GAMES.find((g) => g.id.toLowerCase() === target) ?? null;
|
||||
}
|
||||
180
src/commands/index.ts
Normal file
180
src/commands/index.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Editor, MarkdownView, Notice } from "obsidian";
|
||||
import type CaissaPlugin from "../main";
|
||||
import { OpeningPickerModal } from "../ui/opening-picker";
|
||||
import { EndgamePickerModal } from "../ui/endgame-picker";
|
||||
import { WccMatchPickerModal } from "../ui/wcc-picker";
|
||||
import { OpeningsQuizModal } from "../ui/openings-quiz";
|
||||
import { parseLichessId } from "../chess/lichess-games";
|
||||
|
||||
/**
|
||||
* Register all editor / command palette commands. Stable command IDs only —
|
||||
* never rename these once released.
|
||||
*/
|
||||
export function registerCommands(plugin: CaissaPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-board",
|
||||
name: "Insert chess board",
|
||||
editorCallback: (editor: Editor) => {
|
||||
insertSnippet(editor, ["```chess", "", "```", ""].join("\n"));
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-opening",
|
||||
name: "Insert chess opening from library",
|
||||
editorCheckCallback: (
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
view: MarkdownView
|
||||
) => {
|
||||
if (!view) return false;
|
||||
if (checking) return true;
|
||||
new OpeningPickerModal(plugin.app, (result) => {
|
||||
const lines = ["```chess"];
|
||||
lines.push(`opening: ${result.openingName}`);
|
||||
if (result.variationName) {
|
||||
lines.push(`variation: ${result.variationName}`);
|
||||
}
|
||||
lines.push("```", "");
|
||||
insertSnippet(editor, lines.join("\n"));
|
||||
}).open();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-endgame",
|
||||
name: "Insert chess endgame from library",
|
||||
editorCheckCallback: (
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
view: MarkdownView
|
||||
) => {
|
||||
if (!view) return false;
|
||||
if (checking) return true;
|
||||
new EndgamePickerModal(plugin.app, (endgame) => {
|
||||
const lines = ["```chess", `endgame: ${endgame.id}`, "```", ""];
|
||||
insertSnippet(editor, lines.join("\n"));
|
||||
}).open();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-wcc-game",
|
||||
name: "Insert chess world championship game",
|
||||
editorCheckCallback: (
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
view: MarkdownView
|
||||
) => {
|
||||
if (!view) return false;
|
||||
if (checking) return true;
|
||||
new WccMatchPickerModal(plugin.app, (game) => {
|
||||
const lines = ["```chess", `wccgame: ${game.id}`, "```", ""];
|
||||
insertSnippet(editor, lines.join("\n"));
|
||||
}).open();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-from-fen",
|
||||
name: "Insert chess board from clipboard fen",
|
||||
editorCallback: (editor: Editor) => {
|
||||
const clip = (navigator.clipboard as Clipboard | undefined)
|
||||
?.readText
|
||||
? navigator.clipboard.readText().catch(() => "")
|
||||
: Promise.resolve("");
|
||||
void clip.then((text) => {
|
||||
const fen = looksLikeFen(text) ? text.trim() : "";
|
||||
const lines = ["```chess"];
|
||||
lines.push(`fen: ${fen || "<paste FEN here>"}`);
|
||||
lines.push("```", "");
|
||||
insertSnippet(editor, lines.join("\n"));
|
||||
if (!fen) {
|
||||
new Notice("Replace <paste FEN here> with a real FEN string.");
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-from-lichess",
|
||||
name: "Insert chess game from lichess link",
|
||||
editorCallback: (editor: Editor) => {
|
||||
const clip = (navigator.clipboard as Clipboard | undefined)
|
||||
?.readText
|
||||
? navigator.clipboard.readText().catch(() => "")
|
||||
: Promise.resolve("");
|
||||
void clip.then((text) => {
|
||||
const id = parseLichessId(text ?? "");
|
||||
const lines = ["```chess"];
|
||||
lines.push(`lichess: ${id ?? "<paste lichess game url here>"}`);
|
||||
lines.push("```", "");
|
||||
insertSnippet(editor, lines.join("\n"));
|
||||
if (!id) {
|
||||
new Notice(
|
||||
"Replace <paste lichess game url here> with a real Lichess game URL or ID."
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "start-openings-quiz",
|
||||
name: "Start openings quiz",
|
||||
callback: () => {
|
||||
new OpeningsQuizModal(plugin.app, plugin.settings).open();
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "insert-chess-from-pgn",
|
||||
name: "Insert chess game from clipboard pgn",
|
||||
editorCallback: (editor: Editor) => {
|
||||
const clip = (navigator.clipboard as Clipboard | undefined)
|
||||
?.readText
|
||||
? navigator.clipboard.readText().catch(() => "")
|
||||
: Promise.resolve("");
|
||||
void clip.then((text) => {
|
||||
const pgn = (text ?? "").trim();
|
||||
const lines = ["```chess"];
|
||||
if (pgn) {
|
||||
lines.push(pgn);
|
||||
} else {
|
||||
lines.push("<paste PGN here>");
|
||||
}
|
||||
lines.push("```", "");
|
||||
insertSnippet(editor, lines.join("\n"));
|
||||
if (!pgn) {
|
||||
new Notice(
|
||||
"Replace <paste PGN here> with PGN text (headers and/or moves)."
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert a snippet at the cursor, leaving the cursor on the first blank line. */
|
||||
function insertSnippet(editor: Editor, snippet: string): void {
|
||||
const cursor = editor.getCursor();
|
||||
editor.replaceRange(snippet, cursor);
|
||||
editor.setCursor({
|
||||
line: cursor.line + 1,
|
||||
ch: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeFen(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const t = text.trim();
|
||||
const parts = t.split(/\s+/);
|
||||
if (parts.length < 4) return false;
|
||||
const placement = parts[0] ?? "";
|
||||
if (!/^[1-8prnbqkPRNBQK/]+$/.test(placement)) return false;
|
||||
if (placement.split("/").length !== 8) return false;
|
||||
return true;
|
||||
}
|
||||
45
src/main.ts
Normal file
45
src/main.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import { DEFAULT_SETTINGS, type CaissaSettings } from "./types";
|
||||
import { CaissaSettingTab } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
import { renderChessBlock } from "./ui/chess-block";
|
||||
import { parseBlockConfig } from "./utils/parser";
|
||||
import { terminateEngine } from "./chess/engine-worker";
|
||||
|
||||
export default class CaissaPlugin extends Plugin {
|
||||
settings: CaissaSettings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
|
||||
this.addSettingTab(new CaissaSettingTab(this.app, this));
|
||||
|
||||
registerCommands(this);
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor("chess", (source, el, ctx) => {
|
||||
const config = parseBlockConfig(source);
|
||||
renderChessBlock({
|
||||
host: el,
|
||||
config,
|
||||
settings: this.settings,
|
||||
app: this.app,
|
||||
ctx,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
// Tear down the shared Stockfish worker so reload/disable doesn't
|
||||
// leak the (possibly running) analyzer.
|
||||
terminateEngine();
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const data = (await this.loadData()) as Partial<CaissaSettings> | null;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, data ?? {});
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
292
src/settings.ts
Normal file
292
src/settings.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import type CaissaPlugin from "./main";
|
||||
import {
|
||||
BOARD_COLOR_PRESETS,
|
||||
DEFAULT_SETTINGS,
|
||||
type ExplorerSource,
|
||||
type Orientation,
|
||||
type PieceSet,
|
||||
} from "./types";
|
||||
|
||||
const CUSTOM_PRESET_ID = "custom";
|
||||
|
||||
/** Match the current light/dark colors against the preset list. */
|
||||
function matchPresetId(light: string, dark: string): string {
|
||||
const l = light.trim().toLowerCase();
|
||||
const d = dark.trim().toLowerCase();
|
||||
const hit = BOARD_COLOR_PRESETS.find(
|
||||
(p) => p.light.toLowerCase() === l && p.dark.toLowerCase() === d
|
||||
);
|
||||
return hit?.id ?? CUSTOM_PRESET_ID;
|
||||
}
|
||||
|
||||
export class CaissaSettingTab extends PluginSettingTab {
|
||||
plugin: CaissaPlugin;
|
||||
|
||||
constructor(app: App, plugin: CaissaPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl).setName("Appearance").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Piece set")
|
||||
.setDesc("Style used to draw the chess pieces.")
|
||||
.addDropdown((dd) => {
|
||||
const options: Array<[PieceSet, string]> = [
|
||||
["cburnett", "Cburnett"],
|
||||
["merida", "Merida"],
|
||||
["staunty", "Staunty"],
|
||||
["caliente", "Caliente"],
|
||||
["pixel", "Pixel"],
|
||||
["letter", "Letter"],
|
||||
["unicode", "Unicode"],
|
||||
];
|
||||
for (const [value, label] of options) {
|
||||
dd.addOption(value, label);
|
||||
}
|
||||
dd
|
||||
.setValue(this.plugin.settings.pieceSet)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.pieceSet = value as PieceSet;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Color preset")
|
||||
.setDesc(
|
||||
"Quick-select a board color scheme, or choose custom to set the light and dark square colors below by hand."
|
||||
)
|
||||
.addDropdown((dd) => {
|
||||
for (const preset of BOARD_COLOR_PRESETS) {
|
||||
dd.addOption(preset.id, preset.label);
|
||||
}
|
||||
dd.addOption(CUSTOM_PRESET_ID, "Custom");
|
||||
dd.setValue(
|
||||
matchPresetId(
|
||||
this.plugin.settings.lightSquareColor,
|
||||
this.plugin.settings.darkSquareColor
|
||||
)
|
||||
).onChange(async (value) => {
|
||||
if (value === CUSTOM_PRESET_ID) {
|
||||
return;
|
||||
}
|
||||
const preset = BOARD_COLOR_PRESETS.find(
|
||||
(p) => p.id === value
|
||||
);
|
||||
if (!preset) return;
|
||||
this.plugin.settings.lightSquareColor = preset.light;
|
||||
this.plugin.settings.darkSquareColor = preset.dark;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Light square color")
|
||||
.setDesc("CSS color for light squares.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder(DEFAULT_SETTINGS.lightSquareColor)
|
||||
.setValue(this.plugin.settings.lightSquareColor)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.lightSquareColor =
|
||||
value || DEFAULT_SETTINGS.lightSquareColor;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Dark square color")
|
||||
.setDesc("CSS color for dark squares.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder(DEFAULT_SETTINGS.darkSquareColor)
|
||||
.setValue(this.plugin.settings.darkSquareColor)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.darkSquareColor =
|
||||
value || DEFAULT_SETTINGS.darkSquareColor;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Last move highlight")
|
||||
.setDesc("Color used to highlight the most recent move.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder(DEFAULT_SETTINGS.lastMoveColor)
|
||||
.setValue(this.plugin.settings.lastMoveColor)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.lastMoveColor =
|
||||
value || DEFAULT_SETTINGS.lastMoveColor;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show coordinates")
|
||||
.setDesc("Display rank and file labels on the board.")
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.showCoordinates)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showCoordinates = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName("Defaults").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Default orientation")
|
||||
.setDesc("Side shown at the bottom of the board for new boards.")
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOption("white", "White")
|
||||
.addOption("black", "Black")
|
||||
.setValue(this.plugin.settings.defaultOrientation)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultOrientation =
|
||||
value as Orientation;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show interactive controls")
|
||||
.setDesc(
|
||||
"Show first/previous/next/last and flip buttons under each board."
|
||||
)
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.showInteractiveControls)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showInteractiveControls = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show captured pieces")
|
||||
.setDesc(
|
||||
"Show captured-pieces trays and material balance above and below each board."
|
||||
)
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.showCapturedPieces)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showCapturedPieces = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName("Opening explorer").setHeading();
|
||||
|
||||
const explorerDesc = containerEl.createDiv({
|
||||
cls: "setting-item-description chess-study-explorer-disclosure",
|
||||
});
|
||||
explorerDesc.createSpan({
|
||||
text:
|
||||
"When enabled, the plugin makes authenticated GET requests to " +
|
||||
"https://explorer.lichess.ovh per board position to fetch " +
|
||||
"win/draw/loss statistics. Only the position (FEN) and your " +
|
||||
"Lichess API token are sent. Off by default.",
|
||||
});
|
||||
|
||||
const tokenDesc = document.createDocumentFragment();
|
||||
tokenDesc.appendChild(
|
||||
document.createTextNode(
|
||||
"Required by lichess as of 2026. Create a token (no scopes needed) at "
|
||||
)
|
||||
);
|
||||
const tokenLink = document.createElement("a");
|
||||
tokenLink.href = "https://lichess.org/account/oauth/token";
|
||||
tokenLink.textContent = "lichess.org/account/oauth/token";
|
||||
tokenDesc.appendChild(tokenLink);
|
||||
tokenDesc.appendChild(
|
||||
document.createTextNode(", then paste it here.")
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Lichess API token")
|
||||
.setDesc(tokenDesc)
|
||||
.addText((t) => {
|
||||
t.inputEl.setAttribute("type", "password");
|
||||
t.inputEl.setAttribute("autocomplete", "off");
|
||||
t.inputEl.setAttribute("spellcheck", "false");
|
||||
t
|
||||
.setPlaceholder("Paste your token here")
|
||||
.setValue(this.plugin.settings.lichessApiToken)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.lichessApiToken = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable opening explorer")
|
||||
.setDesc(
|
||||
"Show win/draw/loss statistics for the current position next to each board."
|
||||
)
|
||||
.addToggle((t) =>
|
||||
t
|
||||
.setValue(this.plugin.settings.enableOpeningExplorer)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableOpeningExplorer = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Data source")
|
||||
.setDesc(
|
||||
"Choose between the masters database (high-rated over-the-board games) or the lichess players database."
|
||||
)
|
||||
.addDropdown((dd) =>
|
||||
dd
|
||||
.addOption("masters", "Masters database")
|
||||
.addOption("lichess", "Lichess players")
|
||||
.setValue(this.plugin.settings.explorerSource)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.explorerSource =
|
||||
value as ExplorerSource;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Maximum lines to show")
|
||||
.setDesc("Cap on candidate moves displayed per position.")
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder(String(DEFAULT_SETTINGS.explorerMaxLines))
|
||||
.setValue(String(this.plugin.settings.explorerMaxLines))
|
||||
.onChange(async (value) => {
|
||||
const n = parseInt(value, 10);
|
||||
this.plugin.settings.explorerMaxLines =
|
||||
isNaN(n) || n < 1
|
||||
? DEFAULT_SETTINGS.explorerMaxLines
|
||||
: Math.min(n, 50);
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).addButton((b) =>
|
||||
b
|
||||
.setButtonText("Reset to defaults")
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
208
src/types.ts
Normal file
208
src/types.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
export type PieceColor = "w" | "b";
|
||||
export type PieceType = "p" | "n" | "b" | "r" | "q" | "k";
|
||||
export type Orientation = "white" | "black";
|
||||
export type PieceSet =
|
||||
| "cburnett"
|
||||
| "merida"
|
||||
| "staunty"
|
||||
| "caliente"
|
||||
| "pixel"
|
||||
| "letter"
|
||||
| "unicode";
|
||||
|
||||
/** All SVG-backed piece set IDs (i.e. everything except the pure-text fallback). */
|
||||
export const SVG_PIECE_SETS: ReadonlyArray<PieceSet> = [
|
||||
"cburnett",
|
||||
"merida",
|
||||
"staunty",
|
||||
"caliente",
|
||||
"pixel",
|
||||
"letter",
|
||||
];
|
||||
|
||||
/** All piece set IDs that the parser/settings UI should accept. */
|
||||
export const ALL_PIECE_SETS: ReadonlyArray<PieceSet> = [
|
||||
...SVG_PIECE_SETS,
|
||||
"unicode",
|
||||
];
|
||||
export type ExplorerSource = "masters" | "lichess";
|
||||
export type BoardSize = "small" | "medium" | "large" | "full";
|
||||
|
||||
export interface CaissaSettings {
|
||||
pieceSet: PieceSet;
|
||||
lightSquareColor: string;
|
||||
darkSquareColor: string;
|
||||
lastMoveColor: string;
|
||||
coordinateColor: string;
|
||||
defaultOrientation: Orientation;
|
||||
showCoordinates: boolean;
|
||||
showInteractiveControls: boolean;
|
||||
/** Show captured-pieces trays + material balance below/above the board. */
|
||||
showCapturedPieces: boolean;
|
||||
/**
|
||||
* Master switch for the Opening Explorer panel. When enabled, the plugin
|
||||
* makes anonymous GET requests to https://explorer.lichess.ovh per
|
||||
* position to fetch win/draw/loss statistics. OFF by default — see
|
||||
* README "Privacy" section.
|
||||
*/
|
||||
enableOpeningExplorer: boolean;
|
||||
explorerSource: ExplorerSource;
|
||||
explorerMaxLines: number;
|
||||
/**
|
||||
* Lichess personal access token. The Opening Explorer API requires
|
||||
* authentication as of March 2026. Create one at
|
||||
* https://lichess.org/account/oauth/token (no scopes needed).
|
||||
*/
|
||||
lichessApiToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named color presets for board squares. Picking a preset in the settings UI
|
||||
* just writes the two colors below into {@link CaissaSettings}; the rest
|
||||
* of the renderer keeps reading the raw color strings as before.
|
||||
*/
|
||||
export interface BoardColorPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
}
|
||||
|
||||
/** Built-in board color presets, in display order. */
|
||||
export const BOARD_COLOR_PRESETS: ReadonlyArray<BoardColorPreset> = [
|
||||
{
|
||||
id: "brown",
|
||||
label: "Brown",
|
||||
light: "#f0d9b5",
|
||||
dark: "#b58863",
|
||||
},
|
||||
{
|
||||
id: "green",
|
||||
label: "Green",
|
||||
light: "#eeeed2",
|
||||
dark: "#769656",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SETTINGS: CaissaSettings = {
|
||||
pieceSet: "cburnett",
|
||||
lightSquareColor: "#f0d9b5",
|
||||
darkSquareColor: "#b58863",
|
||||
lastMoveColor: "rgba(155, 199, 0, 0.41)",
|
||||
coordinateColor: "#7a6a4a",
|
||||
defaultOrientation: "white",
|
||||
showCoordinates: true,
|
||||
showInteractiveControls: true,
|
||||
showCapturedPieces: true,
|
||||
enableOpeningExplorer: false,
|
||||
explorerSource: "masters",
|
||||
explorerMaxLines: 15,
|
||||
lichessApiToken: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Headers parsed from a PGN, plus a few derived fields. Display-only —
|
||||
* the engine doesn't use these for move generation.
|
||||
*/
|
||||
export interface PgnHeaders {
|
||||
event?: string;
|
||||
site?: string;
|
||||
date?: string;
|
||||
round?: string;
|
||||
white?: string;
|
||||
black?: string;
|
||||
result?: string;
|
||||
whiteElo?: string;
|
||||
blackElo?: string;
|
||||
whiteTitle?: string;
|
||||
blackTitle?: string;
|
||||
eco?: string;
|
||||
openingName?: string;
|
||||
timeControl?: string;
|
||||
termination?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-board configuration parsed out of a ```chess code block.
|
||||
* All fields are optional — a totally empty block renders the start position.
|
||||
*/
|
||||
export interface ChessBlockConfig {
|
||||
opening?: string;
|
||||
variation?: string;
|
||||
/** Endgame technique ID/name (e.g. "B+N mate", "lucena"). */
|
||||
endgame?: string;
|
||||
/** WCC game slug (e.g. "1972-fischer-spassky-06"). */
|
||||
wccgame?: string;
|
||||
moves?: string;
|
||||
/** Full PGN text (headers + moves). Wins over `moves` if both are set. */
|
||||
pgn?: string;
|
||||
/** Lichess game ID or full https://lichess.org/<id> URL. */
|
||||
lichess?: string;
|
||||
fen?: string;
|
||||
orientation?: Orientation;
|
||||
pieces?: PieceSet;
|
||||
light?: string;
|
||||
dark?: string;
|
||||
title?: string;
|
||||
interactive?: boolean;
|
||||
coordinates?: boolean;
|
||||
startMove?: number;
|
||||
/** When false, the moves panel is hidden and the board takes the row alone. */
|
||||
showMoves?: boolean;
|
||||
/**
|
||||
* Visual size of the board. `small`/`medium`/`large` cap the board width
|
||||
* and keep the moves panel beside it (collapsing on narrow panes). `full`
|
||||
* stretches the board to the writing-area width with the moves panel
|
||||
* always below.
|
||||
*/
|
||||
size?: BoardSize;
|
||||
/** Override the global "enable explorer" switch for this block only. */
|
||||
explorer?: boolean;
|
||||
explorerSource?: ExplorerSource;
|
||||
/** Override the global "show captured pieces" toggle for this block only. */
|
||||
captured?: boolean;
|
||||
/**
|
||||
* Whitespace-separated arrow tokens. Each token is `<from><to>` with
|
||||
* an optional `-color` suffix. Example: `e2e4 g1f3 d2d4-blue`.
|
||||
*/
|
||||
arrows?: string;
|
||||
/**
|
||||
* Whitespace-separated square highlight tokens. Each token is a
|
||||
* square with an optional `-color` suffix. Example: `d4 e4-red f5-yellow`.
|
||||
*/
|
||||
highlights?: string;
|
||||
/**
|
||||
* When true, lazy-load the bundled Stockfish 10 (asm.js) engine and
|
||||
* show an evaluation bar + top engine lines for the current position.
|
||||
* Off by default to avoid the worker spin-up cost on every block.
|
||||
*/
|
||||
analyze?: boolean;
|
||||
/**
|
||||
* Switch the block into "play vs Stockfish" mode. Set to "white"/"black"
|
||||
* to play that color (engine takes the opposite); "random" picks a
|
||||
* coin-flip side at mount time. The starting position comes from
|
||||
* `fen`/`opening`/`endgame` etc. — whatever you'd normally render is
|
||||
* the starting position of the game.
|
||||
*/
|
||||
play?: "white" | "black" | "random";
|
||||
/**
|
||||
* Stockfish "Skill Level" (0–20). Lower = weaker / more human. Defaults
|
||||
* to 8. Combined with a shallower depth at lower levels so weak moves
|
||||
* also come back fast.
|
||||
*/
|
||||
level?: number;
|
||||
}
|
||||
|
||||
export interface OpeningVariation {
|
||||
name: string;
|
||||
moves: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Opening {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
moves: string;
|
||||
description?: string;
|
||||
variations?: OpeningVariation[];
|
||||
}
|
||||
7
src/types/sftext.d.ts
vendored
Normal file
7
src/types/sftext.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// esbuild loads `.sftext` imports as plain strings (configured in
|
||||
// esbuild.config.mjs). Declaring the module shape here lets TypeScript
|
||||
// type-check imports of vendored asm.js engine source.
|
||||
declare module "*.sftext" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
221
src/ui/analysis-panel.ts
Normal file
221
src/ui/analysis-panel.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import type { AnalysisResult, EngineLine } from "../chess/engine-worker";
|
||||
import { getEngine } from "../chess/engine-worker";
|
||||
import { fenInfo } from "./lines-panel";
|
||||
|
||||
/**
|
||||
* Lazily-mounted Stockfish analysis panel: an evaluation bar plus the
|
||||
* top-N principal variations for the current position.
|
||||
*
|
||||
* Lifecycle is owned by the chess block:
|
||||
* 1. The block calls {@link createAnalysisController} once on mount.
|
||||
* 2. On every position change (step/flip) it calls `controller.update(fen)`.
|
||||
* 3. On dispose it calls `controller.destroy()` to abort any in-flight
|
||||
* search and detach DOM. The shared engine worker keeps running so
|
||||
* subsequent blocks don't pay the spin-up cost again.
|
||||
*/
|
||||
|
||||
export interface AnalysisController {
|
||||
update(fen: string): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface AnalysisOptions {
|
||||
depth?: number;
|
||||
multiPV?: number;
|
||||
}
|
||||
|
||||
export function createAnalysisController(
|
||||
host: HTMLElement,
|
||||
opts: AnalysisOptions = {}
|
||||
): AnalysisController {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-analysis");
|
||||
|
||||
const bar = host.createDiv({ cls: "chess-study-eval-bar" });
|
||||
const fillWhite = bar.createDiv({ cls: "chess-study-eval-fill is-white" });
|
||||
const fillBlack = bar.createDiv({ cls: "chess-study-eval-fill is-black" });
|
||||
const label = bar.createDiv({ cls: "chess-study-eval-label", text: "0.0" });
|
||||
|
||||
const linesEl = host.createDiv({ cls: "chess-study-engine-lines" });
|
||||
linesEl.createDiv({
|
||||
cls: "chess-study-engine-loading",
|
||||
text: "Loading engine…",
|
||||
});
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const update = (fen: string) => {
|
||||
if (destroyed) return;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const myAbort = abortController;
|
||||
|
||||
linesEl.empty();
|
||||
linesEl.createDiv({
|
||||
cls: "chess-study-engine-loading",
|
||||
text: "Analyzing…",
|
||||
});
|
||||
|
||||
void getEngine()
|
||||
.analyze(fen, {
|
||||
depth: opts.depth ?? 16,
|
||||
multiPV: opts.multiPV ?? 3,
|
||||
signal: myAbort.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (destroyed || myAbort.signal.aborted) return;
|
||||
renderAnalysis(linesEl, fillWhite, fillBlack, label, res, fen);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (destroyed) return;
|
||||
linesEl.empty();
|
||||
linesEl.createDiv({
|
||||
cls: "chess-study-engine-error",
|
||||
text: `Engine error: ${(err as Error).message}`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
destroyed = true;
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
host.empty();
|
||||
};
|
||||
|
||||
return { update, destroy };
|
||||
}
|
||||
|
||||
function renderAnalysis(
|
||||
linesEl: HTMLElement,
|
||||
fillWhite: HTMLElement,
|
||||
fillBlack: HTMLElement,
|
||||
label: HTMLElement,
|
||||
res: AnalysisResult,
|
||||
fen: string
|
||||
): void {
|
||||
linesEl.empty();
|
||||
if (res.lines.length === 0) {
|
||||
linesEl.createDiv({
|
||||
cls: "chess-study-engine-empty",
|
||||
text: "No analysis available.",
|
||||
});
|
||||
updateEvalBar(fillWhite, fillBlack, label, null, null, "w");
|
||||
return;
|
||||
}
|
||||
|
||||
const top = res.lines[0];
|
||||
if (!top) return;
|
||||
const { fullMoveNumber, turn } = fenInfo(fen);
|
||||
updateEvalBar(fillWhite, fillBlack, label, top.cp, top.mate, turn);
|
||||
|
||||
for (const line of res.lines) {
|
||||
renderLineRow(linesEl, line, fullMoveNumber, turn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLineRow(
|
||||
host: HTMLElement,
|
||||
line: EngineLine,
|
||||
startFullMove: number,
|
||||
turn: "w" | "b"
|
||||
): void {
|
||||
const row = host.createDiv({ cls: "chess-study-engine-line" });
|
||||
row.createSpan({
|
||||
cls: "chess-study-engine-eval",
|
||||
text: formatScore(line.cp, line.mate, turn),
|
||||
});
|
||||
row.createSpan({
|
||||
cls: "chess-study-engine-depth",
|
||||
text: `d${line.depth}`,
|
||||
});
|
||||
row.createSpan({
|
||||
cls: "chess-study-engine-pv",
|
||||
text: formatPv(line.pv, startFullMove, turn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the engine's score from the side-to-move's perspective, then
|
||||
* flip the sign so the printed value is always from White's perspective
|
||||
* (e.g. "+1.20" = White is up, "-0.40" = Black is up). Mate scores are
|
||||
* always shown as #N from White's POV too.
|
||||
*/
|
||||
function formatScore(
|
||||
cp: number | null,
|
||||
mate: number | null,
|
||||
turn: "w" | "b"
|
||||
): string {
|
||||
if (mate !== null) {
|
||||
const fromWhite = turn === "w" ? mate : -mate;
|
||||
const sign = fromWhite > 0 ? "+" : "-";
|
||||
return `${sign}M${Math.abs(fromWhite)}`;
|
||||
}
|
||||
if (cp !== null) {
|
||||
const fromWhite = (turn === "w" ? cp : -cp) / 100;
|
||||
const sign = fromWhite >= 0 ? "+" : "";
|
||||
return `${sign}${fromWhite.toFixed(2)}`;
|
||||
}
|
||||
return "0.00";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a UCI principal variation as a human-readable move list, e.g.
|
||||
* 1. e4 e5 2. Nf3 Nc6 ...
|
||||
*
|
||||
* We don't have a chess.js instance here, so we fall back to printing
|
||||
* raw UCI moves. This is fine for analysis output where positions update
|
||||
* faster than a human reads — clarity beats SAN for quick scanning.
|
||||
*/
|
||||
function formatPv(pv: string[], startFullMove: number, turn: "w" | "b"): string {
|
||||
const out: string[] = [];
|
||||
let move = startFullMove;
|
||||
let side: "w" | "b" = turn;
|
||||
for (let i = 0; i < pv.length; i++) {
|
||||
const tok = pv[i];
|
||||
if (!tok) continue;
|
||||
if (side === "w") {
|
||||
out.push(`${move}.`);
|
||||
} else if (i === 0) {
|
||||
out.push(`${move}...`);
|
||||
}
|
||||
out.push(tok);
|
||||
if (side === "b") move++;
|
||||
side = side === "w" ? "b" : "w";
|
||||
}
|
||||
return out.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the eval bar fill ratios. We clamp to ~5 pawns so a single
|
||||
* blunder doesn't peg the bar — the numeric label still shows the true
|
||||
* value.
|
||||
*/
|
||||
function updateEvalBar(
|
||||
fillWhite: HTMLElement,
|
||||
fillBlack: HTMLElement,
|
||||
label: HTMLElement,
|
||||
cp: number | null,
|
||||
mate: number | null,
|
||||
turn: "w" | "b"
|
||||
): void {
|
||||
let whitePct = 50;
|
||||
let labelText = "0.00";
|
||||
|
||||
if (mate !== null) {
|
||||
const fromWhite = turn === "w" ? mate : -mate;
|
||||
whitePct = fromWhite > 0 ? 100 : 0;
|
||||
labelText = `${fromWhite > 0 ? "+" : "-"}M${Math.abs(fromWhite)}`;
|
||||
} else if (cp !== null) {
|
||||
const pawnsFromWhite = (turn === "w" ? cp : -cp) / 100;
|
||||
const clamped = Math.max(-5, Math.min(5, pawnsFromWhite));
|
||||
whitePct = 50 + (clamped / 5) * 50;
|
||||
const sign = pawnsFromWhite >= 0 ? "+" : "";
|
||||
labelText = `${sign}${pawnsFromWhite.toFixed(2)}`;
|
||||
}
|
||||
|
||||
fillWhite.style.height = `${whitePct}%`;
|
||||
fillBlack.style.height = `${100 - whitePct}%`;
|
||||
label.setText(labelText);
|
||||
}
|
||||
519
src/ui/board-renderer.ts
Normal file
519
src/ui/board-renderer.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
import type { Orientation, PieceColor, PieceSet, PieceType } from "../types";
|
||||
import {
|
||||
createPieceNode,
|
||||
getPieceGlyph,
|
||||
type PieceKey,
|
||||
} from "../chess/pieces";
|
||||
import type { ArrowSpec, HighlightSpec } from "../utils/annotations";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
|
||||
const SQUARE = 45;
|
||||
|
||||
export interface BoardOptions {
|
||||
fen: string;
|
||||
orientation: Orientation;
|
||||
pieceSet: PieceSet;
|
||||
lightColor: string;
|
||||
darkColor: string;
|
||||
highlightColor: string;
|
||||
coordinateColor: string;
|
||||
showCoordinates: boolean;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Optional declarative arrow overlays drawn on top of the board. */
|
||||
arrows?: ArrowSpec[];
|
||||
/** Optional declarative square ring overlays drawn behind pieces. */
|
||||
highlights?: HighlightSpec[];
|
||||
/**
|
||||
* When set, the board attaches transparent click targets per square and
|
||||
* fires this callback with the clicked square (e.g. "e4"). Used by
|
||||
* play-vs-engine mode and any future click-to-move features.
|
||||
*/
|
||||
onSquareClick?: (square: string) => void;
|
||||
/**
|
||||
* Currently-selected source square, e.g. after the user picks up a
|
||||
* piece. Rendered with a brighter ring than the static `highlights`.
|
||||
*/
|
||||
selectedSquare?: string;
|
||||
/**
|
||||
* Squares that are legal destinations from {@link selectedSquare}.
|
||||
* Empty squares get a small dot, capture squares get a larger ring
|
||||
* (Lichess convention).
|
||||
*/
|
||||
legalTargets?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render (or re-render) a chess board into a host element. Tears down any
|
||||
* previous SVG inside the host and replaces it with a fresh one so it's safe
|
||||
* to call repeatedly when stepping through moves.
|
||||
*/
|
||||
export function renderBoard(host: HTMLElement, options: BoardOptions): void {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-board-host");
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 360 360");
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
svg.classList.add("chess-study-board");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute(
|
||||
"aria-label",
|
||||
`Chess position, ${options.orientation} to move from below`
|
||||
);
|
||||
|
||||
const board = parseFen(options.fen);
|
||||
|
||||
for (let r = 0; r < 8; r++) {
|
||||
for (let f = 0; f < 8; f++) {
|
||||
drawSquare(svg, r, f, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.from && options.to) {
|
||||
drawHighlight(svg, options.from, options.orientation, options.highlightColor);
|
||||
drawHighlight(svg, options.to, options.orientation, options.highlightColor);
|
||||
}
|
||||
|
||||
// User-declared square highlights drawn behind pieces so the piece glyph
|
||||
// is still readable. We use a translucent ring rather than a fill so they
|
||||
// stand apart from the last-move highlight.
|
||||
if (options.highlights?.length) {
|
||||
for (const h of options.highlights) {
|
||||
drawSquareRing(svg, h.square, options.orientation, h.color);
|
||||
}
|
||||
}
|
||||
|
||||
// Selection ring (only one at a time) sits behind the piece so the
|
||||
// glyph is still readable.
|
||||
if (options.selectedSquare) {
|
||||
drawSelectionRing(
|
||||
svg,
|
||||
options.selectedSquare,
|
||||
options.orientation
|
||||
);
|
||||
}
|
||||
|
||||
if (options.showCoordinates) {
|
||||
drawCoordinates(svg, options);
|
||||
}
|
||||
|
||||
for (let r = 0; r < 8; r++) {
|
||||
for (let f = 0; f < 8; f++) {
|
||||
const piece = board[r]?.[f];
|
||||
if (!piece) continue;
|
||||
drawPiece(svg, r, f, piece.color, piece.type, options);
|
||||
}
|
||||
}
|
||||
|
||||
// Arrows draw last so they sit on top of pieces — that's the convention
|
||||
// users know from Lichess and chess.com analysis boards.
|
||||
if (options.arrows?.length) {
|
||||
for (const a of options.arrows) {
|
||||
drawArrow(svg, a.from, a.to, options.orientation, a.color);
|
||||
}
|
||||
}
|
||||
|
||||
// Legal-move dots on top of pieces so the destination markers are
|
||||
// always visible (otherwise a target piece would hide its own ring).
|
||||
if (options.legalTargets?.length) {
|
||||
for (const sq of options.legalTargets) {
|
||||
const occupied = isOccupied(board, sq);
|
||||
drawLegalTarget(svg, sq, options.orientation, occupied);
|
||||
}
|
||||
}
|
||||
|
||||
// Click-target overlays go on top of everything else so they get the
|
||||
// click events first. Pieces are drawn without pointer-events anyway,
|
||||
// but stacking them last is the simplest correct contract.
|
||||
if (options.onSquareClick) {
|
||||
const onClick = options.onSquareClick;
|
||||
for (let r = 0; r < 8; r++) {
|
||||
for (let f = 0; f < 8; f++) {
|
||||
attachClickTarget(svg, r, f, options.orientation, onClick);
|
||||
}
|
||||
}
|
||||
svg.classList.add("is-clickable");
|
||||
}
|
||||
|
||||
host.appendChild(svg);
|
||||
}
|
||||
|
||||
interface PieceCell {
|
||||
color: PieceColor;
|
||||
type: PieceType;
|
||||
}
|
||||
|
||||
/** Returns an 8x8 array indexed [rank-from-top][file-from-left]. */
|
||||
function parseFen(fen: string): (PieceCell | null)[][] {
|
||||
const board: (PieceCell | null)[][] = [];
|
||||
const placement = fen.split(" ")[0] ?? "";
|
||||
const ranks = placement.split("/");
|
||||
for (const rankStr of ranks) {
|
||||
const row: (PieceCell | null)[] = [];
|
||||
for (const ch of rankStr) {
|
||||
if (/[1-8]/.test(ch)) {
|
||||
const n = parseInt(ch, 10);
|
||||
for (let i = 0; i < n; i++) row.push(null);
|
||||
} else {
|
||||
const isWhite = ch === ch.toUpperCase();
|
||||
row.push({
|
||||
color: isWhite ? "w" : "b",
|
||||
type: ch.toLowerCase() as PieceType,
|
||||
});
|
||||
}
|
||||
}
|
||||
while (row.length < 8) row.push(null);
|
||||
board.push(row);
|
||||
}
|
||||
while (board.length < 8) board.push(new Array<PieceCell | null>(8).fill(null));
|
||||
return board;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert (rank-from-top, file-from-left) into pixel (x, y) for the given
|
||||
* orientation. Each square is 45x45 inside a 360x360 viewBox.
|
||||
*/
|
||||
function squareToXY(
|
||||
r: number,
|
||||
f: number,
|
||||
orientation: Orientation
|
||||
): { x: number; y: number } {
|
||||
if (orientation === "white") {
|
||||
return { x: f * 45, y: r * 45 };
|
||||
}
|
||||
return { x: (7 - f) * 45, y: (7 - r) * 45 };
|
||||
}
|
||||
|
||||
/** Convert algebraic ("e4") into a (rank-from-top, file-from-left) pair. */
|
||||
function algebraicToRC(square: string): { r: number; f: number } | null {
|
||||
if (square.length !== 2) return null;
|
||||
const file = square.charCodeAt(0) - "a".charCodeAt(0);
|
||||
const rank = parseInt(square[1] ?? "", 10);
|
||||
if (file < 0 || file > 7 || isNaN(rank) || rank < 1 || rank > 8) {
|
||||
return null;
|
||||
}
|
||||
return { r: 8 - rank, f: file };
|
||||
}
|
||||
|
||||
function drawSquare(
|
||||
svg: SVGElement,
|
||||
r: number,
|
||||
f: number,
|
||||
opts: BoardOptions
|
||||
): void {
|
||||
const { x, y } = squareToXY(r, f, opts.orientation);
|
||||
const isLight = (r + f) % 2 === 0;
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", String(x));
|
||||
rect.setAttribute("y", String(y));
|
||||
rect.setAttribute("width", "45");
|
||||
rect.setAttribute("height", "45");
|
||||
rect.setAttribute(
|
||||
"fill",
|
||||
isLight ? opts.lightColor : opts.darkColor
|
||||
);
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
|
||||
function drawHighlight(
|
||||
svg: SVGElement,
|
||||
square: string,
|
||||
orientation: Orientation,
|
||||
color: string
|
||||
): void {
|
||||
const rc = algebraicToRC(square);
|
||||
if (!rc) return;
|
||||
const { x, y } = squareToXY(rc.r, rc.f, orientation);
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", String(x));
|
||||
rect.setAttribute("y", String(y));
|
||||
rect.setAttribute("width", String(SQUARE));
|
||||
rect.setAttribute("height", String(SQUARE));
|
||||
rect.setAttribute("fill", color);
|
||||
rect.setAttribute("pointer-events", "none");
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a selection ring around the picked-up piece. We use a yellow
|
||||
* translucent fill (so the underlying square color still shows through)
|
||||
* to match the convention from major chess sites.
|
||||
*/
|
||||
function drawSelectionRing(
|
||||
svg: SVGElement,
|
||||
square: string,
|
||||
orientation: Orientation
|
||||
): void {
|
||||
const rc = algebraicToRC(square);
|
||||
if (!rc) return;
|
||||
const { x, y } = squareToXY(rc.r, rc.f, orientation);
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", String(x));
|
||||
rect.setAttribute("y", String(y));
|
||||
rect.setAttribute("width", String(SQUARE));
|
||||
rect.setAttribute("height", String(SQUARE));
|
||||
rect.setAttribute("fill", "rgba(255, 230, 0, 0.45)");
|
||||
rect.setAttribute("pointer-events", "none");
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a small dot (empty square) or a hollow ring (capture square) on
|
||||
* a legal-move target. The ring sits at the square's edge so the piece
|
||||
* glyph underneath stays visible — matching Lichess.
|
||||
*/
|
||||
function drawLegalTarget(
|
||||
svg: SVGElement,
|
||||
square: string,
|
||||
orientation: Orientation,
|
||||
occupied: boolean
|
||||
): void {
|
||||
const rc = algebraicToRC(square);
|
||||
if (!rc) return;
|
||||
const { x, y } = squareToXY(rc.r, rc.f, orientation);
|
||||
const cx = x + SQUARE / 2;
|
||||
const cy = y + SQUARE / 2;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
if (occupied) {
|
||||
circle.setAttribute("r", String(SQUARE / 2 - 2));
|
||||
circle.setAttribute("fill", "none");
|
||||
circle.setAttribute("stroke", "rgba(0, 0, 0, 0.35)");
|
||||
circle.setAttribute("stroke-width", "4");
|
||||
} else {
|
||||
circle.setAttribute("r", String(SQUARE / 6));
|
||||
circle.setAttribute("fill", "rgba(0, 0, 0, 0.35)");
|
||||
}
|
||||
circle.setAttribute("pointer-events", "none");
|
||||
svg.appendChild(circle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an invisible per-square hit target on top of everything. Each
|
||||
* carries its algebraic name in `data-square` so the SVG-level click
|
||||
* handler can route the event without recomputing geometry.
|
||||
*/
|
||||
function attachClickTarget(
|
||||
svg: SVGElement,
|
||||
r: number,
|
||||
f: number,
|
||||
orientation: Orientation,
|
||||
onClick: (square: string) => void
|
||||
): void {
|
||||
const { x, y } = squareToXY(r, f, orientation);
|
||||
const file = "abcdefgh".charAt(f);
|
||||
const rank = String(8 - r);
|
||||
const square = `${file}${rank}`;
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", String(x));
|
||||
rect.setAttribute("y", String(y));
|
||||
rect.setAttribute("width", String(SQUARE));
|
||||
rect.setAttribute("height", String(SQUARE));
|
||||
rect.setAttribute("fill", "transparent");
|
||||
// Default SVG pointer-events is "visiblePainted" — a transparent fill
|
||||
// wouldn't capture clicks under that policy. Force "all" so the
|
||||
// invisible target still receives events and routes them.
|
||||
rect.setAttribute("pointer-events", "all");
|
||||
rect.classList.add("chess-study-board-target");
|
||||
rect.addEventListener("click", () => onClick(square));
|
||||
svg.appendChild(rect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick "is there a piece on this square?" check against the parsed FEN
|
||||
* grid. Used so legal-target rendering can pick "ring" vs "dot".
|
||||
*/
|
||||
function isOccupied(
|
||||
board: (PieceCell | null)[][],
|
||||
square: string
|
||||
): boolean {
|
||||
const rc = algebraicToRC(square);
|
||||
if (!rc) return false;
|
||||
return board[rc.r]?.[rc.f] != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a translucent ring inside a square — used for user-declared
|
||||
* `highlights:` annotations. Inset slightly so the ring sits inside the
|
||||
* square edge instead of bleeding into neighbors.
|
||||
*/
|
||||
function drawSquareRing(
|
||||
svg: SVGElement,
|
||||
square: string,
|
||||
orientation: Orientation,
|
||||
color: string
|
||||
): void {
|
||||
const rc = algebraicToRC(square);
|
||||
if (!rc) return;
|
||||
const { x, y } = squareToXY(rc.r, rc.f, orientation);
|
||||
const cx = x + SQUARE / 2;
|
||||
const cy = y + SQUARE / 2;
|
||||
const radius = SQUARE / 2 - 2;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(radius));
|
||||
circle.setAttribute("fill", "none");
|
||||
circle.setAttribute("stroke", color);
|
||||
circle.setAttribute("stroke-width", "3");
|
||||
circle.setAttribute("pointer-events", "none");
|
||||
svg.appendChild(circle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a thick arrow from one square to another. The arrowhead is a
|
||||
* filled triangle whose base sits at the edge of the destination square,
|
||||
* and the shaft is shortened so the head sits cleanly on top of it
|
||||
* rather than poking past it.
|
||||
*/
|
||||
function drawArrow(
|
||||
svg: SVGElement,
|
||||
from: string,
|
||||
to: string,
|
||||
orientation: Orientation,
|
||||
color: string
|
||||
): void {
|
||||
const fromRC = algebraicToRC(from);
|
||||
const toRC = algebraicToRC(to);
|
||||
if (!fromRC || !toRC) return;
|
||||
|
||||
const fromXY = squareToXY(fromRC.r, fromRC.f, orientation);
|
||||
const toXY = squareToXY(toRC.r, toRC.f, orientation);
|
||||
|
||||
const x1 = fromXY.x + SQUARE / 2;
|
||||
const y1 = fromXY.y + SQUARE / 2;
|
||||
const x2 = toXY.x + SQUARE / 2;
|
||||
const y2 = toXY.y + SQUARE / 2;
|
||||
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1) return;
|
||||
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
|
||||
const shaftWidth = 7;
|
||||
const headLen = 14;
|
||||
const headWidth = 16;
|
||||
|
||||
// Shorten the shaft so the arrowhead's tip is the actual end-of-arrow,
|
||||
// and offset the start a touch so it doesn't sit dead-center on the
|
||||
// piece glyph.
|
||||
const startInset = 8;
|
||||
const endInset = headLen;
|
||||
const sx = x1 + ux * startInset;
|
||||
const sy = y1 + uy * startInset;
|
||||
const ex = x2 - ux * endInset;
|
||||
const ey = y2 - uy * endInset;
|
||||
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
g.setAttribute("pointer-events", "none");
|
||||
|
||||
const shaft = document.createElementNS(SVG_NS, "line");
|
||||
shaft.setAttribute("x1", String(sx));
|
||||
shaft.setAttribute("y1", String(sy));
|
||||
shaft.setAttribute("x2", String(ex));
|
||||
shaft.setAttribute("y2", String(ey));
|
||||
shaft.setAttribute("stroke", color);
|
||||
shaft.setAttribute("stroke-width", String(shaftWidth));
|
||||
shaft.setAttribute("stroke-linecap", "butt");
|
||||
g.appendChild(shaft);
|
||||
|
||||
// Triangular head: tip at (x2,y2), base perpendicular to the shaft
|
||||
// `headLen` pixels back from the tip.
|
||||
const baseCx = x2 - ux * headLen;
|
||||
const baseCy = y2 - uy * headLen;
|
||||
const px = -uy;
|
||||
const py = ux;
|
||||
const baseAx = baseCx + px * (headWidth / 2);
|
||||
const baseAy = baseCy + py * (headWidth / 2);
|
||||
const baseBx = baseCx - px * (headWidth / 2);
|
||||
const baseBy = baseCy - py * (headWidth / 2);
|
||||
|
||||
const head = document.createElementNS(SVG_NS, "polygon");
|
||||
head.setAttribute(
|
||||
"points",
|
||||
`${x2},${y2} ${baseAx},${baseAy} ${baseBx},${baseBy}`
|
||||
);
|
||||
head.setAttribute("fill", color);
|
||||
g.appendChild(head);
|
||||
|
||||
svg.appendChild(g);
|
||||
}
|
||||
|
||||
function drawCoordinates(svg: SVGElement, opts: BoardOptions): void {
|
||||
const orient = opts.orientation;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const fileChar = orient === "white" ? FILES[i] : FILES[7 - i];
|
||||
const rankChar = orient === "white" ? String(8 - i) : String(i + 1);
|
||||
|
||||
const fileLabel = document.createElementNS(SVG_NS, "text");
|
||||
fileLabel.setAttribute("x", String(i * 45 + 38));
|
||||
fileLabel.setAttribute("y", "356");
|
||||
fileLabel.setAttribute("font-size", "8");
|
||||
fileLabel.setAttribute("font-family", "system-ui, sans-serif");
|
||||
fileLabel.setAttribute("font-weight", "600");
|
||||
fileLabel.setAttribute("fill", opts.coordinateColor);
|
||||
fileLabel.setAttribute("pointer-events", "none");
|
||||
fileLabel.textContent = fileChar ?? "";
|
||||
svg.appendChild(fileLabel);
|
||||
|
||||
const rankLabel = document.createElementNS(SVG_NS, "text");
|
||||
rankLabel.setAttribute("x", "3");
|
||||
rankLabel.setAttribute("y", String(i * 45 + 11));
|
||||
rankLabel.setAttribute("font-size", "8");
|
||||
rankLabel.setAttribute("font-family", "system-ui, sans-serif");
|
||||
rankLabel.setAttribute("font-weight", "600");
|
||||
rankLabel.setAttribute("fill", opts.coordinateColor);
|
||||
rankLabel.setAttribute("pointer-events", "none");
|
||||
rankLabel.textContent = rankChar;
|
||||
svg.appendChild(rankLabel);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPiece(
|
||||
svg: SVGElement,
|
||||
r: number,
|
||||
f: number,
|
||||
color: PieceColor,
|
||||
type: PieceType,
|
||||
opts: BoardOptions
|
||||
): void {
|
||||
const { x, y } = squareToXY(r, f, opts.orientation);
|
||||
const key: PieceKey = `${color}${type}`;
|
||||
|
||||
if (opts.pieceSet !== "unicode") {
|
||||
const node = createPieceNode(opts.pieceSet, key);
|
||||
if (node) {
|
||||
// Wrap so we don't trample the per-set transform that
|
||||
// createPieceNode may have already applied (for sets whose
|
||||
// source viewBox differs from 45x45).
|
||||
const wrap = document.createElementNS(SVG_NS, "g");
|
||||
wrap.setAttribute("transform", `translate(${x}, ${y})`);
|
||||
wrap.appendChild(node);
|
||||
svg.appendChild(wrap);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode fallback (or explicit unicode mode).
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(x + 22.5));
|
||||
text.setAttribute("y", String(y + 36));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("font-size", "38");
|
||||
text.setAttribute(
|
||||
"font-family",
|
||||
"'Segoe UI Symbol', 'Noto Sans Symbols 2', system-ui, sans-serif"
|
||||
);
|
||||
text.setAttribute("fill", color === "w" ? "#fafafa" : "#1a1a1a");
|
||||
text.setAttribute("stroke", color === "w" ? "#1a1a1a" : "#fafafa");
|
||||
text.setAttribute("stroke-width", "0.6");
|
||||
text.setAttribute("pointer-events", "none");
|
||||
text.textContent = getPieceGlyph(key);
|
||||
svg.appendChild(text);
|
||||
}
|
||||
106
src/ui/captured-tray.ts
Normal file
106
src/ui/captured-tray.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { PieceColor, PieceSet, PieceType } from "../types";
|
||||
import { createPieceNode, getPieceGlyph, type PieceKey } from "../chess/pieces";
|
||||
import {
|
||||
TRAY_PIECE_ORDER,
|
||||
type CapturedTotals,
|
||||
} from "../utils/captured-pieces";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export interface TrayArgs {
|
||||
/** Which side's captures we're showing — drives piece color and label. */
|
||||
color: PieceColor;
|
||||
totals: CapturedTotals;
|
||||
pieceSet: PieceSet;
|
||||
/**
|
||||
* Material advantage from White's perspective. We only render an
|
||||
* "+N" badge on the side that's actually ahead so it never duplicates.
|
||||
*/
|
||||
showAdvantage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single captured-pieces tray (one of two — the other is the
|
||||
* opposite color shown above/below the board). Each row contains:
|
||||
*
|
||||
* [piece] x N [piece] x N ... [+advantage]
|
||||
*
|
||||
* The pieces themselves are mini SVGs cloned from the active piece set so
|
||||
* the tray styles match the board. Pawns are listed first (lowest value)
|
||||
* up to queens, matching Lichess and chess.com conventions.
|
||||
*/
|
||||
export function renderCapturedTray(host: HTMLElement, args: TrayArgs): void {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-captured");
|
||||
|
||||
const counts =
|
||||
args.color === "w" ? args.totals.byWhite : args.totals.byBlack;
|
||||
|
||||
// "Pieces captured by white" means *black* pieces — and vice versa.
|
||||
// The renderer below draws those former-opponent pieces.
|
||||
const drawColor: PieceColor = args.color === "w" ? "b" : "w";
|
||||
|
||||
for (const type of TRAY_PIECE_ORDER) {
|
||||
const n = counts[type];
|
||||
if (n <= 0) continue;
|
||||
appendPieceGroup(host, drawColor, type, n, args.pieceSet);
|
||||
}
|
||||
|
||||
if (args.showAdvantage && args.totals.advantage !== 0) {
|
||||
const adv = args.totals.advantage;
|
||||
// Show on whichever side is ahead.
|
||||
const showHere =
|
||||
(args.color === "w" && adv > 0) ||
|
||||
(args.color === "b" && adv < 0);
|
||||
if (showHere) {
|
||||
host.createSpan({
|
||||
cls: "chess-study-captured-adv",
|
||||
text: `+${Math.abs(adv)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a group like "[♟️ ♟️ ♟️]" for `count` copies of one piece type.
|
||||
* Multiple copies overlap slightly to mirror the "stack" look from
|
||||
* Lichess so a 7-pawn capture doesn't take up half the row.
|
||||
*/
|
||||
function appendPieceGroup(
|
||||
host: HTMLElement,
|
||||
color: PieceColor,
|
||||
type: PieceType,
|
||||
count: number,
|
||||
pieceSet: PieceSet
|
||||
): void {
|
||||
const group = host.createSpan({ cls: "chess-study-captured-group" });
|
||||
for (let i = 0; i < count; i++) {
|
||||
appendMiniPiece(group, color, type, pieceSet);
|
||||
}
|
||||
}
|
||||
|
||||
function appendMiniPiece(
|
||||
host: HTMLElement,
|
||||
color: PieceColor,
|
||||
type: PieceType,
|
||||
pieceSet: PieceSet
|
||||
): void {
|
||||
const key: PieceKey = `${color}${type}`;
|
||||
const wrap = host.createSpan({ cls: "chess-study-captured-piece" });
|
||||
|
||||
if (pieceSet !== "unicode") {
|
||||
const node = createPieceNode(pieceSet, key);
|
||||
if (node) {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 45 45");
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
svg.classList.add("chess-study-captured-svg");
|
||||
svg.appendChild(node);
|
||||
wrap.appendChild(svg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wrap.classList.add("chess-study-captured-glyph");
|
||||
wrap.textContent = getPieceGlyph(key);
|
||||
}
|
||||
751
src/ui/chess-block.ts
Normal file
751
src/ui/chess-block.ts
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
import { App, MarkdownPostProcessorContext, Menu, Notice, setIcon } from "obsidian";
|
||||
import type { ChessBlockConfig, CaissaSettings, PgnHeaders } from "../types";
|
||||
import { buildPositions, type BuildResult, type PositionStep } from "../chess/engine";
|
||||
import { fetchExplorer } from "../chess/explorer";
|
||||
import { fetchLichessGame } from "../chess/lichess-games";
|
||||
import { renderBoard } from "./board-renderer";
|
||||
import { renderMoveList } from "./move-list";
|
||||
import { fenInfo, renderLinesPanel } from "./lines-panel";
|
||||
import { renderInlinePicker } from "./inline-picker";
|
||||
import { rewriteChessBlock } from "../utils/block-rewrite";
|
||||
import { parseArrows, parseHighlights } from "../utils/annotations";
|
||||
import {
|
||||
computeCaptured,
|
||||
hasAnyCaptures,
|
||||
} from "../utils/captured-pieces";
|
||||
import { renderCapturedTray } from "./captured-tray";
|
||||
import {
|
||||
copyBoardAsPng,
|
||||
copyBoardAsSvg,
|
||||
downloadBoardAsPng,
|
||||
downloadBoardAsSvg,
|
||||
} from "../utils/export-board";
|
||||
import {
|
||||
createAnalysisController,
|
||||
type AnalysisController,
|
||||
} from "./analysis-panel";
|
||||
import { createEvalGraphController } from "./eval-graph";
|
||||
import { renderPlayBlock } from "./play-controller";
|
||||
|
||||
export interface ChessBlockViewArgs {
|
||||
host: HTMLElement;
|
||||
config: ChessBlockConfig;
|
||||
settings: CaissaSettings;
|
||||
/** Obsidian app handle, used to rewrite the source file when the inline picker is used. */
|
||||
app: App;
|
||||
/** Markdown context for locating the block's line range in the source file. */
|
||||
ctx: MarkdownPostProcessorContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an entire interactive chess study block (board + moves + controls)
|
||||
* into a host element. Stores its mutable state (current step + orientation)
|
||||
* locally and re-renders on each interaction.
|
||||
*/
|
||||
export function renderChessBlock(args: ChessBlockViewArgs): void {
|
||||
const { host, config, settings, app, ctx } = args;
|
||||
host.empty();
|
||||
host.classList.add("chess-study-block");
|
||||
|
||||
if (config.lichess) {
|
||||
renderRemoteLichessBlock(host, config, settings, app, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = buildPositions({
|
||||
opening: config.opening,
|
||||
variation: config.variation,
|
||||
endgame: config.endgame,
|
||||
wccgame: config.wccgame,
|
||||
moves: config.moves,
|
||||
pgn: config.pgn,
|
||||
fen: config.fen,
|
||||
});
|
||||
|
||||
// Play-vs-Stockfish hijacks the entire block: the user is making
|
||||
// moves, not stepping a fixed sequence, so the standard mountResult
|
||||
// flow doesn't apply. We pull the *first* (starting) FEN from the
|
||||
// build result so any opening/endgame/FEN config still seeds the game.
|
||||
if (config.play && result.steps.length > 0 && !result.error) {
|
||||
const startStep =
|
||||
result.steps[Math.max(0, (config.startMove ?? 0) | 0)] ??
|
||||
result.steps[0];
|
||||
if (startStep) {
|
||||
renderPlayBlock({
|
||||
host,
|
||||
startFen: startStep.fen,
|
||||
humanColor: pickHumanColor(config.play),
|
||||
level: config.level ?? 8,
|
||||
config,
|
||||
settings,
|
||||
app,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
mountResult(host, config, settings, result, app, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `play:` config (which can be "white", "black", or "random")
|
||||
* to a concrete piece color. Random is decided once at mount time so the
|
||||
* orientation doesn't flip mid-game on a re-render.
|
||||
*/
|
||||
function pickHumanColor(play: NonNullable<ChessBlockConfig["play"]>): "w" | "b" {
|
||||
if (play === "white") return "w";
|
||||
if (play === "black") return "b";
|
||||
return Math.random() < 0.5 ? "w" : "b";
|
||||
}
|
||||
|
||||
/**
|
||||
* Async path: fetch a Lichess game over HTTPS, then render it. Shows a
|
||||
* loading shimmer while in flight and an inline error if it fails.
|
||||
*/
|
||||
function renderRemoteLichessBlock(
|
||||
host: HTMLElement,
|
||||
config: ChessBlockConfig,
|
||||
settings: CaissaSettings,
|
||||
app: App,
|
||||
ctx: MarkdownPostProcessorContext
|
||||
): void {
|
||||
const id = config.lichess?.trim() ?? "";
|
||||
|
||||
if (config.title) {
|
||||
host.createDiv({ cls: "chess-study-title", text: config.title });
|
||||
}
|
||||
|
||||
const loading = host.createDiv({
|
||||
cls: "chess-study-loading",
|
||||
text: `Loading Lichess game…`,
|
||||
});
|
||||
|
||||
fetchLichessGame(id)
|
||||
.then((pgn) => {
|
||||
loading.remove();
|
||||
const result = buildPositions({ pgn });
|
||||
mountResult(host, config, settings, result, app, ctx);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
loading.remove();
|
||||
host.createDiv({
|
||||
cls: "chess-study-error",
|
||||
text: `Could not load Lichess game "${id}": ${err.message}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared mount path: takes a {@link BuildResult} and renders headers, the
|
||||
* board, the move list, controls, and the optional explorer panel.
|
||||
*/
|
||||
function mountResult(
|
||||
host: HTMLElement,
|
||||
config: ChessBlockConfig,
|
||||
settings: CaissaSettings,
|
||||
result: BuildResult,
|
||||
app: App,
|
||||
ctx: MarkdownPostProcessorContext
|
||||
): void {
|
||||
// If the block has no concrete position content (no pgn/moves/fen/lichess),
|
||||
// show the cascading category picker above the board so the user can pick
|
||||
// a starting position without editing the source manually. The picker
|
||||
// rewrites the block's source on change, which triggers a re-render.
|
||||
const showInlinePicker =
|
||||
!config.pgn && !config.moves && !config.fen && !config.lichess;
|
||||
if (showInlinePicker) {
|
||||
renderPickerStrip(host, config, app, ctx);
|
||||
}
|
||||
|
||||
if (config.title) {
|
||||
host.createDiv({ cls: "chess-study-title", text: config.title });
|
||||
} else if (result.headers) {
|
||||
renderGameHeader(host, result.headers);
|
||||
} else if (result.resolvedTitle) {
|
||||
host.createDiv({
|
||||
cls: "chess-study-title",
|
||||
text: result.resolvedTitle,
|
||||
});
|
||||
if (result.resolvedDescription) {
|
||||
host.createDiv({
|
||||
cls: "chess-study-description",
|
||||
text: result.resolvedDescription,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const size = config.size ?? "medium";
|
||||
// Default to showing the moves panel even when the block has no moves
|
||||
// (yet) — it reserves the space and constrains the picker bars to a
|
||||
// natural width. The user can opt out with `showMoves: false`.
|
||||
const showMoves = config.showMoves ?? true;
|
||||
|
||||
const layout = host.createDiv({
|
||||
cls: `chess-study-layout size-${size}${showMoves ? "" : " no-moves"}`,
|
||||
attr: {
|
||||
tabindex: "0",
|
||||
role: "group",
|
||||
"aria-label":
|
||||
"Chess study board. Arrow keys step through moves, F flips the board.",
|
||||
},
|
||||
});
|
||||
const boardCol = layout.createDiv({ cls: "chess-study-board-col" });
|
||||
const topTrayHost = boardCol.createDiv({
|
||||
cls: "chess-study-captured-wrap chess-study-captured-top",
|
||||
});
|
||||
const boardHost = boardCol.createDiv({ cls: "chess-study-board-wrap" });
|
||||
const bottomTrayHost = boardCol.createDiv({
|
||||
cls: "chess-study-captured-wrap chess-study-captured-bottom",
|
||||
});
|
||||
const controlsHost = boardCol.createDiv({ cls: "chess-study-controls" });
|
||||
|
||||
const sideCol = showMoves
|
||||
? layout.createDiv({ cls: "chess-study-side-col" })
|
||||
: null;
|
||||
const movesHost =
|
||||
sideCol?.createDiv({ cls: "chess-study-moves-wrap" }) ?? null;
|
||||
|
||||
const analyzeEnabled = config.analyze === true;
|
||||
const analysisHost = analyzeEnabled
|
||||
? (sideCol ?? boardCol).createDiv({
|
||||
cls: "chess-study-analysis-wrap",
|
||||
})
|
||||
: null;
|
||||
const evalGraphHost = analyzeEnabled
|
||||
? host.createDiv({ cls: "chess-study-eval-graph-wrap" })
|
||||
: null;
|
||||
let analysisController: AnalysisController | null = null;
|
||||
let evalGraphController: ReturnType<typeof createEvalGraphController> | null =
|
||||
null;
|
||||
if (analysisHost) {
|
||||
analysisController = createAnalysisController(analysisHost);
|
||||
}
|
||||
|
||||
const explorerEnabled =
|
||||
config.explorer ?? settings.enableOpeningExplorer;
|
||||
const explorerSource =
|
||||
config.explorerSource ?? settings.explorerSource;
|
||||
|
||||
const linesHost = explorerEnabled
|
||||
? host.createDiv({ cls: "chess-study-lines-wrap" })
|
||||
: null;
|
||||
|
||||
const showControls =
|
||||
config.interactive ?? settings.showInteractiveControls;
|
||||
const showCoords = config.coordinates ?? settings.showCoordinates;
|
||||
|
||||
const state = {
|
||||
index: clampIndex(
|
||||
config.startMove ?? result.steps.length - 1,
|
||||
result.steps
|
||||
),
|
||||
orientation:
|
||||
config.orientation ?? settings.defaultOrientation,
|
||||
fetchToken: 0,
|
||||
};
|
||||
|
||||
const updateLinesPanel = (step: PositionStep) => {
|
||||
if (!linesHost) return;
|
||||
const myToken = ++state.fetchToken;
|
||||
const { fullMoveNumber, turn } = fenInfo(step.fen);
|
||||
renderLinesPanel(linesHost, { kind: "loading" });
|
||||
fetchExplorer(step.fen, explorerSource, settings.lichessApiToken)
|
||||
.then((res) => {
|
||||
if (myToken !== state.fetchToken) return;
|
||||
if (res.totalGames === 0 && res.moves.length === 0) {
|
||||
renderLinesPanel(linesHost, {
|
||||
kind: "empty",
|
||||
source: explorerSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderLinesPanel(linesHost, {
|
||||
kind: "ready",
|
||||
result: res,
|
||||
fullMoveNumber,
|
||||
turn,
|
||||
maxLines: settings.explorerMaxLines,
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (myToken !== state.fetchToken) return;
|
||||
renderLinesPanel(linesHost, {
|
||||
kind: "error",
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const arrows = parseArrows(config.arrows);
|
||||
const highlights = parseHighlights(config.highlights);
|
||||
const showCaptured =
|
||||
config.captured ?? settings.showCapturedPieces;
|
||||
const pieceSet = config.pieces ?? settings.pieceSet;
|
||||
|
||||
const drawCapturedTrays = () => {
|
||||
if (!showCaptured) {
|
||||
topTrayHost.empty();
|
||||
bottomTrayHost.empty();
|
||||
topTrayHost.classList.add("is-hidden");
|
||||
bottomTrayHost.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
const totals = computeCaptured(result.steps, state.index);
|
||||
// If the position has no captures (e.g. a fresh starting board or
|
||||
// a custom-FEN endgame study), hide the trays entirely so they
|
||||
// don't add empty vertical space.
|
||||
if (!hasAnyCaptures(totals)) {
|
||||
topTrayHost.empty();
|
||||
bottomTrayHost.empty();
|
||||
topTrayHost.classList.add("is-hidden");
|
||||
bottomTrayHost.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
topTrayHost.classList.remove("is-hidden");
|
||||
bottomTrayHost.classList.remove("is-hidden");
|
||||
// The tray on a side belongs to the player on that side. With
|
||||
// orientation=white, white sits at the bottom, so the bottom tray
|
||||
// shows white's captures and the top tray shows black's.
|
||||
const bottomColor = state.orientation === "white" ? "w" : "b";
|
||||
const topColor = bottomColor === "w" ? "b" : "w";
|
||||
renderCapturedTray(topTrayHost, {
|
||||
color: topColor,
|
||||
totals,
|
||||
pieceSet,
|
||||
showAdvantage: true,
|
||||
});
|
||||
renderCapturedTray(bottomTrayHost, {
|
||||
color: bottomColor,
|
||||
totals,
|
||||
pieceSet,
|
||||
showAdvantage: true,
|
||||
});
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const step = result.steps[state.index] ?? result.steps[0];
|
||||
if (!step) return;
|
||||
renderBoard(boardHost, {
|
||||
fen: step.fen,
|
||||
orientation: state.orientation,
|
||||
pieceSet,
|
||||
lightColor: config.light ?? settings.lightSquareColor,
|
||||
darkColor: config.dark ?? settings.darkSquareColor,
|
||||
highlightColor: settings.lastMoveColor,
|
||||
coordinateColor: settings.coordinateColor,
|
||||
showCoordinates: showCoords,
|
||||
from: step.from,
|
||||
to: step.to,
|
||||
arrows,
|
||||
highlights,
|
||||
});
|
||||
drawCapturedTrays();
|
||||
if (movesHost) {
|
||||
renderMoveList(movesHost, {
|
||||
steps: result.steps,
|
||||
activeIndex: state.index,
|
||||
onSelect: (idx) => {
|
||||
state.index = clampIndex(idx, result.steps);
|
||||
draw();
|
||||
},
|
||||
});
|
||||
}
|
||||
updateLinesPanel(step);
|
||||
analysisController?.update(step.fen);
|
||||
evalGraphController?.setActiveIndex(state.index);
|
||||
};
|
||||
|
||||
const goFirst = () => {
|
||||
state.index = 0;
|
||||
draw();
|
||||
};
|
||||
const goPrev = () => {
|
||||
state.index = clampIndex(state.index - 1, result.steps);
|
||||
draw();
|
||||
};
|
||||
const goNext = () => {
|
||||
state.index = clampIndex(state.index + 1, result.steps);
|
||||
draw();
|
||||
};
|
||||
const goLast = () => {
|
||||
state.index = result.steps.length - 1;
|
||||
draw();
|
||||
};
|
||||
const flip = () => {
|
||||
state.orientation =
|
||||
state.orientation === "white" ? "black" : "white";
|
||||
draw();
|
||||
};
|
||||
|
||||
if (showControls) {
|
||||
buildControls(controlsHost, {
|
||||
canStep: result.steps.length > 1,
|
||||
onFirst: goFirst,
|
||||
onPrev: goPrev,
|
||||
onNext: goNext,
|
||||
onLast: goLast,
|
||||
onFlip: flip,
|
||||
});
|
||||
}
|
||||
|
||||
bindKeyboardShortcuts(layout, {
|
||||
canStep: result.steps.length > 1,
|
||||
onFirst: goFirst,
|
||||
onPrev: goPrev,
|
||||
onNext: goNext,
|
||||
onLast: goLast,
|
||||
onFlip: flip,
|
||||
});
|
||||
|
||||
bindBoardExportMenu(boardHost, () => result.steps[state.index]?.fen);
|
||||
|
||||
if (evalGraphHost && result.steps.length > 1) {
|
||||
evalGraphController = createEvalGraphController(evalGraphHost, {
|
||||
steps: result.steps,
|
||||
onJumpTo: (idx) => {
|
||||
state.index = clampIndex(idx, result.steps);
|
||||
draw();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
host.createDiv({
|
||||
cls: "chess-study-error",
|
||||
text: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a structured header strip from PGN tags. Skips gracefully if no
|
||||
* meaningful fields are present.
|
||||
*/
|
||||
function renderGameHeader(host: HTMLElement, headers: PgnHeaders): void {
|
||||
const hasMeaningfulHeader =
|
||||
headers.white || headers.black || headers.event;
|
||||
if (!hasMeaningfulHeader) return;
|
||||
|
||||
const wrap = host.createDiv({ cls: "chess-study-game-header" });
|
||||
|
||||
if (headers.white || headers.black) {
|
||||
const players = wrap.createDiv({ cls: "chess-study-players" });
|
||||
appendPlayer(players, "white", headers.white, headers.whiteElo, headers.whiteTitle);
|
||||
players.createSpan({ cls: "chess-study-versus", text: "vs" });
|
||||
appendPlayer(players, "black", headers.black, headers.blackElo, headers.blackTitle);
|
||||
if (headers.result) {
|
||||
players.createSpan({
|
||||
cls: "chess-study-result",
|
||||
text: prettyResult(headers.result),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const metaBits: string[] = [];
|
||||
if (headers.event) metaBits.push(headers.event);
|
||||
if (headers.round && headers.round !== "-") metaBits.push(`Round ${headers.round}`);
|
||||
if (headers.date && headers.date !== "????.??.??") metaBits.push(headers.date);
|
||||
if (headers.eco && headers.openingName) {
|
||||
metaBits.push(`${headers.eco} · ${headers.openingName}`);
|
||||
} else if (headers.eco) {
|
||||
metaBits.push(headers.eco);
|
||||
} else if (headers.openingName) {
|
||||
metaBits.push(headers.openingName);
|
||||
}
|
||||
if (metaBits.length) {
|
||||
wrap.createDiv({ cls: "chess-study-game-meta", text: metaBits.join(" · ") });
|
||||
}
|
||||
}
|
||||
|
||||
function appendPlayer(
|
||||
host: HTMLElement,
|
||||
color: "white" | "black",
|
||||
name: string | undefined,
|
||||
elo: string | undefined,
|
||||
title: string | undefined
|
||||
): void {
|
||||
const wrap = host.createSpan({ cls: `chess-study-player chess-study-player-${color}` });
|
||||
wrap.createSpan({ cls: "chess-study-player-dot", attr: { "aria-hidden": "true" } });
|
||||
if (title) {
|
||||
wrap.createSpan({ cls: "chess-study-player-title", text: title });
|
||||
}
|
||||
wrap.createSpan({ cls: "chess-study-player-name", text: name ?? "?" });
|
||||
if (elo) {
|
||||
wrap.createSpan({ cls: "chess-study-player-elo", text: `(${elo})` });
|
||||
}
|
||||
}
|
||||
|
||||
function prettyResult(result: string): string {
|
||||
switch (result) {
|
||||
case "1-0":
|
||||
return "1–0";
|
||||
case "0-1":
|
||||
return "0–1";
|
||||
case "1/2-1/2":
|
||||
return "½–½";
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the inline category/opening/endgame/WCC picker strip above the
|
||||
* board. Any change rewrites the block's source via {@link rewriteChessBlock},
|
||||
* which causes Obsidian to re-render the block with the updated config.
|
||||
*
|
||||
* Each emitted update clears every category-specific key (opening, variation,
|
||||
* endgame, wccgame) and reapplies only the ones present in the update — that
|
||||
* way switching between categories doesn't leave stale keys behind.
|
||||
*/
|
||||
function renderPickerStrip(
|
||||
host: HTMLElement,
|
||||
config: ChessBlockConfig,
|
||||
app: App,
|
||||
ctx: MarkdownPostProcessorContext
|
||||
): void {
|
||||
const pickerHost = host.createDiv({ cls: "chess-study-inline-picker-wrap" });
|
||||
renderInlinePicker(pickerHost, {
|
||||
currentOpening: config.opening,
|
||||
currentVariation: config.variation,
|
||||
currentEndgame: config.endgame,
|
||||
currentWccGame: config.wccgame,
|
||||
onChange: (update) => {
|
||||
void rewriteChessBlock(app, ctx, host, {
|
||||
opening: update.opening ?? "",
|
||||
variation: update.variation ?? "",
|
||||
endgame: update.endgame ?? "",
|
||||
wccgame: update.wccgame ?? "",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clampIndex(idx: number, steps: PositionStep[]): number {
|
||||
if (steps.length === 0) return 0;
|
||||
if (idx < 0) return 0;
|
||||
if (idx > steps.length - 1) return steps.length - 1;
|
||||
return idx;
|
||||
}
|
||||
|
||||
interface ControlsArgs {
|
||||
canStep: boolean;
|
||||
onFirst: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onLast: () => void;
|
||||
onFlip: () => void;
|
||||
}
|
||||
|
||||
function buildControls(host: HTMLElement, args: ControlsArgs): void {
|
||||
host.empty();
|
||||
|
||||
const make = (
|
||||
label: string,
|
||||
icon: string,
|
||||
onClick: () => void,
|
||||
opts: { disabled?: boolean } = {}
|
||||
) => {
|
||||
const btn = host.createEl("button", {
|
||||
cls: "chess-study-control",
|
||||
attr: { "aria-label": label, type: "button", title: label },
|
||||
});
|
||||
setIcon(btn, icon);
|
||||
if (opts.disabled) btn.setAttribute("disabled", "true");
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
};
|
||||
|
||||
make("Start position", "chevrons-left", args.onFirst, {
|
||||
disabled: !args.canStep,
|
||||
});
|
||||
make("Previous move (←)", "chevron-left", args.onPrev, {
|
||||
disabled: !args.canStep,
|
||||
});
|
||||
make("Next move (→)", "chevron-right", args.onNext, {
|
||||
disabled: !args.canStep,
|
||||
});
|
||||
make("Last move", "chevrons-right", args.onLast, {
|
||||
disabled: !args.canStep,
|
||||
});
|
||||
|
||||
host.createSpan({ cls: "chess-study-control-spacer" });
|
||||
|
||||
make("Flip board (F)", "refresh-cw", args.onFlip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up keyboard shortcuts that fire when the block (or any descendant
|
||||
* that isn't a text input) has focus. Shortcuts:
|
||||
*
|
||||
* ← / Page Up — previous move
|
||||
* → / Page Down — next move
|
||||
* Home — first position
|
||||
* End — last position
|
||||
* F — flip the board
|
||||
*
|
||||
* The block container is given `tabindex="0"` so users can tab into it
|
||||
* (and click also focuses it). We deliberately scope the listener to the
|
||||
* block so multiple blocks on the same page don't fight over keys, and
|
||||
* we ignore events targeted at editable elements so typing in a future
|
||||
* search/quiz field would still work.
|
||||
*/
|
||||
function bindKeyboardShortcuts(
|
||||
layout: HTMLElement,
|
||||
args: ControlsArgs
|
||||
): void {
|
||||
layout.addEventListener("keydown", (ev) => {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target && isEditableTarget(target)) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case "ArrowLeft":
|
||||
case "PageUp":
|
||||
if (!args.canStep) return;
|
||||
args.onPrev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case "PageDown":
|
||||
if (!args.canStep) return;
|
||||
args.onNext();
|
||||
break;
|
||||
case "Home":
|
||||
if (!args.canStep) return;
|
||||
args.onFirst();
|
||||
break;
|
||||
case "End":
|
||||
if (!args.canStep) return;
|
||||
args.onLast();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||||
args.onFlip();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
// Click anywhere inside the block focuses it so keyboard shortcuts
|
||||
// take effect immediately — without this users would have to Tab in.
|
||||
layout.addEventListener("mousedown", (ev) => {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target && isEditableTarget(target)) return;
|
||||
// Defer so any control-button click handlers run with normal focus first.
|
||||
window.setTimeout(() => layout.focus({ preventScroll: true }), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function isEditableTarget(el: HTMLElement): boolean {
|
||||
const tag = el.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (el.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click (or long-press on touch) on the board to open an export menu:
|
||||
*
|
||||
* - Copy as image (PNG to clipboard)
|
||||
* - Copy as SVG (XML text to clipboard)
|
||||
* - Save as PNG (download)
|
||||
* - Save as SVG (download)
|
||||
*
|
||||
* `getCurrentFen` is read lazily so the exported image always reflects the
|
||||
* board's *current* position (after stepping/flipping), not the one shown
|
||||
* when the block first rendered.
|
||||
*/
|
||||
function bindBoardExportMenu(
|
||||
boardHost: HTMLElement,
|
||||
getCurrentFen: () => string | undefined
|
||||
): void {
|
||||
boardHost.addEventListener("contextmenu", (ev) => {
|
||||
const svg = boardHost.querySelector("svg");
|
||||
if (!svg) return;
|
||||
ev.preventDefault();
|
||||
|
||||
const filename = boardFilename(getCurrentFen());
|
||||
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Copy as image")
|
||||
.setIcon("clipboard-copy")
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await copyBoardAsPng(svg as SVGElement);
|
||||
new Notice("Board copied as PNG");
|
||||
} catch (err) {
|
||||
new Notice(`Copy failed: ${(err as Error).message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Copy as SVG")
|
||||
.setIcon("clipboard-copy")
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await copyBoardAsSvg(svg as SVGElement);
|
||||
new Notice("Board SVG copied to clipboard");
|
||||
} catch (err) {
|
||||
new Notice(`Copy failed: ${(err as Error).message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Save as PNG")
|
||||
.setIcon("image-down")
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await downloadBoardAsPng(
|
||||
svg as SVGElement,
|
||||
`${filename}.png`
|
||||
);
|
||||
} catch (err) {
|
||||
new Notice(
|
||||
`Save failed: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle("Save as SVG")
|
||||
.setIcon("image-down")
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await downloadBoardAsSvg(
|
||||
svg as SVGElement,
|
||||
`${filename}.svg`
|
||||
);
|
||||
} catch (err) {
|
||||
new Notice(
|
||||
`Save failed: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
menu.showAtMouseEvent(ev);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sensible filename from a FEN: a fixed `caissa-board-` prefix plus
|
||||
* the board placement segment (with `/` replaced so it's filesystem-safe).
|
||||
* Falls back to a timestamp if no FEN is available.
|
||||
*/
|
||||
function boardFilename(fen: string | undefined): string {
|
||||
if (!fen) {
|
||||
return `caissa-board-${Date.now()}`;
|
||||
}
|
||||
const placement = fen.split(" ")[0] ?? "";
|
||||
const safe = placement.replace(/\//g, "-").slice(0, 80);
|
||||
return `caissa-board-${safe || Date.now()}`;
|
||||
}
|
||||
28
src/ui/endgame-picker.ts
Normal file
28
src/ui/endgame-picker.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { App, FuzzySuggestModal } from "obsidian";
|
||||
import { ENDGAMES, type Endgame } from "../chess/endgames";
|
||||
|
||||
/**
|
||||
* Fuzzy-search modal for the curated endgame library. Single step — endgames
|
||||
* are flat, no sub-categories.
|
||||
*/
|
||||
export class EndgamePickerModal extends FuzzySuggestModal<Endgame> {
|
||||
private readonly onChoose: (endgame: Endgame) => void;
|
||||
|
||||
constructor(app: App, onChoose: (endgame: Endgame) => void) {
|
||||
super(app);
|
||||
this.onChoose = onChoose;
|
||||
this.setPlaceholder("Search endgames");
|
||||
}
|
||||
|
||||
getItems(): Endgame[] {
|
||||
return ENDGAMES;
|
||||
}
|
||||
|
||||
getItemText(item: Endgame): string {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
onChooseItem(item: Endgame): void {
|
||||
this.onChoose(item);
|
||||
}
|
||||
}
|
||||
316
src/ui/eval-graph.ts
Normal file
316
src/ui/eval-graph.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import type { PositionStep } from "../chess/engine";
|
||||
import { getEngine } from "../chess/engine-worker";
|
||||
|
||||
/**
|
||||
* Eval graph: an SVG sparkline of Stockfish's centipawn evaluation across
|
||||
* every position in the loaded game. Hidden behind an "Analyze game"
|
||||
* button so we never run the engine over an entire game without the
|
||||
* user opting in (analyzing 80 positions at depth 14 is non-trivial).
|
||||
*
|
||||
* Click anywhere on the graph to jump the board to the closest position.
|
||||
*
|
||||
* The controller exposes `setActiveIndex` so the chess block can keep the
|
||||
* cursor on the graph in sync with stepping/clicking through moves.
|
||||
*/
|
||||
|
||||
export interface EvalGraphArgs {
|
||||
steps: PositionStep[];
|
||||
onJumpTo: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface EvalGraphController {
|
||||
setActiveIndex(index: number): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface EvalPoint {
|
||||
index: number;
|
||||
/** Centipawns from White's perspective (clamped at ±1500). null = mate. */
|
||||
cp: number | null;
|
||||
mate: number | null;
|
||||
}
|
||||
|
||||
const GRAPH_WIDTH = 600;
|
||||
const GRAPH_HEIGHT = 80;
|
||||
const PADDING_Y = 6;
|
||||
const CLAMP_CP = 800;
|
||||
|
||||
export function createEvalGraphController(
|
||||
host: HTMLElement,
|
||||
args: EvalGraphArgs
|
||||
): EvalGraphController {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-eval-graph");
|
||||
|
||||
const header = host.createDiv({ cls: "chess-study-eval-graph-header" });
|
||||
header.createSpan({
|
||||
cls: "chess-study-eval-graph-title",
|
||||
text: "Evaluation",
|
||||
});
|
||||
const button = header.createEl("button", {
|
||||
cls: "chess-study-eval-graph-btn",
|
||||
text: "Analyze game",
|
||||
attr: { type: "button" },
|
||||
});
|
||||
const status = header.createSpan({
|
||||
cls: "chess-study-eval-graph-status",
|
||||
text: "",
|
||||
});
|
||||
|
||||
const graphHost = host.createDiv({ cls: "chess-study-eval-graph-canvas" });
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let destroyed = false;
|
||||
let evals: EvalPoint[] = [];
|
||||
let activeIndex = 0;
|
||||
let svg: SVGSVGElement | null = null;
|
||||
let cursor: SVGLineElement | null = null;
|
||||
|
||||
const render = () => {
|
||||
graphHost.empty();
|
||||
svg = drawGraph(graphHost, evals, args.steps.length, activeIndex);
|
||||
cursor = svg.querySelector(".chess-study-eval-graph-cursor");
|
||||
svg.addEventListener("click", (ev) => {
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
const ratio = (ev.clientX - rect.left) / rect.width;
|
||||
const idx = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
args.steps.length - 1,
|
||||
Math.round(ratio * (args.steps.length - 1))
|
||||
)
|
||||
);
|
||||
args.onJumpTo(idx);
|
||||
});
|
||||
};
|
||||
|
||||
const updateCursor = () => {
|
||||
if (!cursor || args.steps.length <= 1) return;
|
||||
const x =
|
||||
(activeIndex / (args.steps.length - 1)) * GRAPH_WIDTH;
|
||||
cursor.setAttribute("x1", String(x));
|
||||
cursor.setAttribute("x2", String(x));
|
||||
};
|
||||
|
||||
const runAnalysis = async () => {
|
||||
if (destroyed) return;
|
||||
button.setAttribute("disabled", "true");
|
||||
button.setText("Analyzing…");
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
evals = await analyzeGame(
|
||||
args.steps,
|
||||
abortController.signal,
|
||||
(done, total) => {
|
||||
if (destroyed) return;
|
||||
status.setText(`${done}/${total}`);
|
||||
}
|
||||
);
|
||||
if (destroyed) return;
|
||||
status.setText(`${evals.length} positions`);
|
||||
button.setText("Re-analyze");
|
||||
button.removeAttribute("disabled");
|
||||
render();
|
||||
} catch (err) {
|
||||
if (destroyed) return;
|
||||
button.removeAttribute("disabled");
|
||||
button.setText("Analyze game");
|
||||
status.setText(`Error: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
void runAnalysis();
|
||||
});
|
||||
|
||||
return {
|
||||
setActiveIndex(idx) {
|
||||
activeIndex = idx;
|
||||
updateCursor();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
host.empty();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the engine on every position sequentially, reporting progress.
|
||||
* Sequential (not parallel) because we share a single Stockfish worker;
|
||||
* shallow depth (12) keeps total time tolerable for a typical game.
|
||||
*/
|
||||
async function analyzeGame(
|
||||
steps: PositionStep[],
|
||||
signal: AbortSignal,
|
||||
onProgress: (done: number, total: number) => void
|
||||
): Promise<EvalPoint[]> {
|
||||
const out: EvalPoint[] = [];
|
||||
const engine = getEngine();
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (signal.aborted) break;
|
||||
const step = steps[i];
|
||||
if (!step) continue;
|
||||
const fen = step.fen;
|
||||
const res = await engine.analyze(fen, { depth: 12, multiPV: 1, signal });
|
||||
const top = res.lines[0];
|
||||
if (!top) {
|
||||
out.push({ index: i, cp: null, mate: null });
|
||||
} else {
|
||||
const turn = fen.split(" ")[1] === "w" ? "w" : "b";
|
||||
const cpFromWhite =
|
||||
top.cp === null
|
||||
? null
|
||||
: turn === "w"
|
||||
? top.cp
|
||||
: -top.cp;
|
||||
const mateFromWhite =
|
||||
top.mate === null
|
||||
? null
|
||||
: turn === "w"
|
||||
? top.mate
|
||||
: -top.mate;
|
||||
out.push({
|
||||
index: i,
|
||||
cp: cpFromWhite,
|
||||
mate: mateFromWhite,
|
||||
});
|
||||
}
|
||||
onProgress(i + 1, steps.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the eval sparkline as an inline SVG. Positions with no evaluation
|
||||
* yet are rendered as a flat line at 0 so the graph still has a shape;
|
||||
* mate scores are clamped to the chart edges so a single mate doesn't
|
||||
* obliterate the rest of the curve.
|
||||
*/
|
||||
function drawGraph(
|
||||
host: HTMLElement,
|
||||
evals: EvalPoint[],
|
||||
totalSteps: number,
|
||||
activeIndex: number
|
||||
): SVGSVGElement {
|
||||
const svgNs = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(svgNs, "svg");
|
||||
svg.setAttribute("class", "chess-study-eval-graph-svg");
|
||||
svg.setAttribute("viewBox", `0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`);
|
||||
svg.setAttribute("preserveAspectRatio", "none");
|
||||
host.appendChild(svg);
|
||||
|
||||
const bg = document.createElementNS(svgNs, "rect");
|
||||
bg.setAttribute("x", "0");
|
||||
bg.setAttribute("y", "0");
|
||||
bg.setAttribute("width", String(GRAPH_WIDTH));
|
||||
bg.setAttribute("height", String(GRAPH_HEIGHT));
|
||||
bg.setAttribute("fill", "var(--background-secondary)");
|
||||
svg.appendChild(bg);
|
||||
|
||||
// Midline (eval = 0).
|
||||
const mid = document.createElementNS(svgNs, "line");
|
||||
mid.setAttribute("x1", "0");
|
||||
mid.setAttribute("x2", String(GRAPH_WIDTH));
|
||||
mid.setAttribute("y1", String(GRAPH_HEIGHT / 2));
|
||||
mid.setAttribute("y2", String(GRAPH_HEIGHT / 2));
|
||||
mid.setAttribute("stroke", "var(--background-modifier-border)");
|
||||
mid.setAttribute("stroke-width", "1");
|
||||
svg.appendChild(mid);
|
||||
|
||||
if (evals.length === 0 || totalSteps <= 1) {
|
||||
const empty = document.createElementNS(svgNs, "text");
|
||||
empty.setAttribute("x", String(GRAPH_WIDTH / 2));
|
||||
empty.setAttribute("y", String(GRAPH_HEIGHT / 2 + 4));
|
||||
empty.setAttribute("text-anchor", "middle");
|
||||
empty.setAttribute("fill", "var(--text-muted)");
|
||||
empty.setAttribute("font-size", "11");
|
||||
empty.textContent =
|
||||
evals.length === 0
|
||||
? "Click \"Analyze game\" to evaluate every position."
|
||||
: "Not enough positions to graph.";
|
||||
svg.appendChild(empty);
|
||||
const cursor = makeCursor(svgNs);
|
||||
svg.appendChild(cursor);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const points = evals.map((p) => {
|
||||
const x = (p.index / (totalSteps - 1)) * GRAPH_WIDTH;
|
||||
const y = scoreToY(p);
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
// Filled area (positive above midline = white advantage).
|
||||
const path = document.createElementNS(svgNs, "path");
|
||||
const d = buildAreaPath(points);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("fill", "var(--text-accent)");
|
||||
path.setAttribute("fill-opacity", "0.25");
|
||||
svg.appendChild(path);
|
||||
|
||||
const stroke = document.createElementNS(svgNs, "polyline");
|
||||
stroke.setAttribute(
|
||||
"points",
|
||||
points.map((p) => `${p.x},${p.y}`).join(" ")
|
||||
);
|
||||
stroke.setAttribute("fill", "none");
|
||||
stroke.setAttribute("stroke", "var(--text-accent)");
|
||||
stroke.setAttribute("stroke-width", "1.5");
|
||||
svg.appendChild(stroke);
|
||||
|
||||
const cursor = makeCursor(svgNs);
|
||||
const cx = (activeIndex / (totalSteps - 1)) * GRAPH_WIDTH;
|
||||
cursor.setAttribute("x1", String(cx));
|
||||
cursor.setAttribute("x2", String(cx));
|
||||
svg.appendChild(cursor);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function makeCursor(_svgNs: string): SVGLineElement {
|
||||
const cursor = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"line"
|
||||
);
|
||||
cursor.setAttribute("class", "chess-study-eval-graph-cursor");
|
||||
cursor.setAttribute("x1", "0");
|
||||
cursor.setAttribute("x2", "0");
|
||||
cursor.setAttribute("y1", "0");
|
||||
cursor.setAttribute("y2", String(GRAPH_HEIGHT));
|
||||
cursor.setAttribute("stroke", "var(--interactive-accent)");
|
||||
cursor.setAttribute("stroke-width", "1.5");
|
||||
cursor.setAttribute("stroke-dasharray", "3 2");
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function buildAreaPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return "";
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
if (!first || !last) return "";
|
||||
const mid = GRAPH_HEIGHT / 2;
|
||||
const parts = [`M ${first.x} ${mid}`];
|
||||
for (const p of points) parts.push(`L ${p.x} ${p.y}`);
|
||||
parts.push(`L ${last.x} ${mid}`);
|
||||
parts.push("Z");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function scoreToY(p: EvalPoint): number {
|
||||
const inner = GRAPH_HEIGHT - PADDING_Y * 2;
|
||||
const mid = GRAPH_HEIGHT / 2;
|
||||
if (p.mate !== null) {
|
||||
return p.mate > 0 ? PADDING_Y : GRAPH_HEIGHT - PADDING_Y;
|
||||
}
|
||||
if (p.cp === null) return mid;
|
||||
const clamped = Math.max(-CLAMP_CP, Math.min(CLAMP_CP, p.cp));
|
||||
const ratio = clamped / CLAMP_CP;
|
||||
return mid - (ratio * inner) / 2;
|
||||
}
|
||||
341
src/ui/inline-picker.ts
Normal file
341
src/ui/inline-picker.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { OPENINGS } from "../chess/openings";
|
||||
import { ENDGAMES } from "../chess/endgames";
|
||||
import { listWccMatches, listWccGamesForMatch } from "../chess/wcc-games";
|
||||
import type { Opening, OpeningVariation } from "../types";
|
||||
|
||||
export interface InlinePickerOptions {
|
||||
/** Currently selected opening (case-insensitive, aliases honored). */
|
||||
currentOpening?: string;
|
||||
/** Currently selected variation, only meaningful with an opening. */
|
||||
currentVariation?: string;
|
||||
/** Currently selected endgame slug or display name. */
|
||||
currentEndgame?: string;
|
||||
/** Currently selected WCC game slug. */
|
||||
currentWccGame?: string;
|
||||
/** All inputs collapsed into a single update — the host applies it atomically. */
|
||||
onChange: (update: PickerUpdate) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the picker emits whenever any dropdown changes. The host should:
|
||||
* 1. clear all category-specific keys it manages (opening/variation/endgame/wccgame)
|
||||
* 2. apply only the keys present in this update
|
||||
*
|
||||
* Empty string values mean "clear this key".
|
||||
*/
|
||||
export interface PickerUpdate {
|
||||
opening?: string;
|
||||
variation?: string;
|
||||
endgame?: string;
|
||||
wccgame?: string;
|
||||
}
|
||||
|
||||
type Category = "openings" | "endgames" | "wcc";
|
||||
|
||||
const CATEGORY_PLACEHOLDER = "__category_placeholder__";
|
||||
const PLACEHOLDER = "__placeholder__";
|
||||
const NO_VARIATION = "__none__";
|
||||
|
||||
const CATEGORY_LABELS: Record<Category, string> = {
|
||||
openings: "Openings",
|
||||
endgames: "Endgames",
|
||||
wcc: "World Championship",
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the cascading category picker for blank chess blocks. The category
|
||||
* dropdown comes first; each category swaps in its own follow-up dropdowns.
|
||||
*/
|
||||
export function renderInlinePicker(
|
||||
host: HTMLElement,
|
||||
opts: InlinePickerOptions
|
||||
): void {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-inline-picker");
|
||||
|
||||
const initialCategory = detectCategory(opts);
|
||||
|
||||
const categoryRow = host.createDiv({ cls: "chess-study-inline-picker-row" });
|
||||
categoryRow.createSpan({
|
||||
cls: "chess-study-inline-picker-label",
|
||||
text: "Category",
|
||||
});
|
||||
const categorySelect = categoryRow.createEl("select", {
|
||||
cls: "chess-study-inline-picker-select",
|
||||
});
|
||||
categorySelect.createEl("option", {
|
||||
value: CATEGORY_PLACEHOLDER,
|
||||
text: "Pick a category…",
|
||||
});
|
||||
for (const id of Object.keys(CATEGORY_LABELS) as Category[]) {
|
||||
categorySelect.createEl("option", { value: id, text: CATEGORY_LABELS[id] });
|
||||
}
|
||||
categorySelect.value = initialCategory ?? CATEGORY_PLACEHOLDER;
|
||||
|
||||
const subRows = host.createDiv({ cls: "chess-study-inline-picker-subrows" });
|
||||
const renderSub = (cat: Category | null) => {
|
||||
subRows.empty();
|
||||
if (cat === "openings") renderOpeningsSub(subRows, opts);
|
||||
else if (cat === "endgames") renderEndgamesSub(subRows, opts);
|
||||
else if (cat === "wcc") renderWccSub(subRows, opts);
|
||||
};
|
||||
renderSub(initialCategory);
|
||||
|
||||
categorySelect.addEventListener("change", () => {
|
||||
const raw = categorySelect.value;
|
||||
if (raw === CATEGORY_PLACEHOLDER) {
|
||||
renderSub(null);
|
||||
opts.onChange({});
|
||||
return;
|
||||
}
|
||||
const cat = raw as Category;
|
||||
renderSub(cat);
|
||||
opts.onChange({});
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Sub-dropdowns ------------------------------------------------------- */
|
||||
|
||||
function renderOpeningsSub(host: HTMLElement, opts: InlinePickerOptions): void {
|
||||
const matchedOpening = findOpening(opts.currentOpening);
|
||||
|
||||
const openingRow = host.createDiv({ cls: "chess-study-inline-picker-row" });
|
||||
openingRow.createSpan({
|
||||
cls: "chess-study-inline-picker-label",
|
||||
text: "Opening",
|
||||
});
|
||||
const openingSelect = openingRow.createEl("select", {
|
||||
cls: "chess-study-inline-picker-select",
|
||||
});
|
||||
openingSelect.createEl("option", {
|
||||
value: PLACEHOLDER,
|
||||
text: "Pick an opening…",
|
||||
});
|
||||
for (const o of OPENINGS) {
|
||||
openingSelect.createEl("option", { value: o.name, text: o.name });
|
||||
}
|
||||
openingSelect.value = matchedOpening?.name ?? PLACEHOLDER;
|
||||
openingSelect.addEventListener("change", () => {
|
||||
const v = openingSelect.value;
|
||||
opts.onChange({
|
||||
opening: v === PLACEHOLDER ? "" : v,
|
||||
variation: "",
|
||||
});
|
||||
});
|
||||
|
||||
if (!matchedOpening || (matchedOpening.variations ?? []).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variationRow = host.createDiv({ cls: "chess-study-inline-picker-row" });
|
||||
variationRow.createSpan({
|
||||
cls: "chess-study-inline-picker-label",
|
||||
text: "Variation",
|
||||
});
|
||||
const variationSelect = variationRow.createEl("select", {
|
||||
cls: "chess-study-inline-picker-select",
|
||||
});
|
||||
variationSelect.createEl("option", {
|
||||
value: NO_VARIATION,
|
||||
text: "Main line",
|
||||
});
|
||||
for (const v of matchedOpening.variations ?? []) {
|
||||
variationSelect.createEl("option", { value: v.name, text: v.name });
|
||||
}
|
||||
const matchedVariation = findVariation(matchedOpening, opts.currentVariation);
|
||||
variationSelect.value = matchedVariation?.name ?? NO_VARIATION;
|
||||
variationSelect.addEventListener("change", () => {
|
||||
const v = variationSelect.value;
|
||||
opts.onChange({
|
||||
opening: matchedOpening.name,
|
||||
variation: v === NO_VARIATION ? "" : v,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderEndgamesSub(host: HTMLElement, opts: InlinePickerOptions): void {
|
||||
const matched = findEndgameByIdOrName(opts.currentEndgame);
|
||||
|
||||
const row = host.createDiv({ cls: "chess-study-inline-picker-row" });
|
||||
row.createSpan({
|
||||
cls: "chess-study-inline-picker-label",
|
||||
text: "Endgame",
|
||||
});
|
||||
const select = row.createEl("select", {
|
||||
cls: "chess-study-inline-picker-select",
|
||||
});
|
||||
select.createEl("option", {
|
||||
value: PLACEHOLDER,
|
||||
text: "Pick an endgame…",
|
||||
});
|
||||
for (const e of ENDGAMES) {
|
||||
select.createEl("option", { value: e.id, text: e.name });
|
||||
}
|
||||
select.value = matched ?? PLACEHOLDER;
|
||||
select.addEventListener("change", () => {
|
||||
const v = select.value;
|
||||
opts.onChange({
|
||||
endgame: v === PLACEHOLDER ? "" : v,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderWccSub(host: HTMLElement, opts: InlinePickerOptions): void {
|
||||
const matches = listWccMatches();
|
||||
const currentGame = opts.currentWccGame
|
||||
? findWccGameByIdLocal(opts.currentWccGame)
|
||||
: null;
|
||||
const initialMatchSlug = currentGame?.matchSlug;
|
||||
|
||||
const matchRow = host.createDiv({ cls: "chess-study-inline-picker-row" });
|
||||
matchRow.createSpan({
|
||||
cls: "chess-study-inline-picker-label",
|
||||
text: "Match",
|
||||
});
|
||||
const matchSelect = matchRow.createEl("select", {
|
||||
cls: "chess-study-inline-picker-select",
|
||||
});
|
||||
matchSelect.createEl("option", {
|
||||
value: PLACEHOLDER,
|
||||
text: "Pick a match…",
|
||||
});
|
||||
for (const m of matches) {
|
||||
matchSelect.createEl("option", {
|
||||
value: m.matchSlug,
|
||||
text: m.matchLabel,
|
||||
});
|
||||
}
|
||||
matchSelect.value = initialMatchSlug ?? PLACEHOLDER;
|
||||
|
||||
const gameRowWrap = host.createDiv({
|
||||
cls: "chess-study-inline-picker-gamerow-wrap",
|
||||
});
|
||||
|
||||
const renderGameRow = (matchSlug: string | null) => {
|
||||
gameRowWrap.empty();
|
||||
if (!matchSlug) return;
|
||||
const games = listWccGamesForMatch(matchSlug);
|
||||
if (games.length === 0) return;
|
||||
|
||||
const row = gameRowWrap.createDiv({
|
||||
cls: "chess-study-inline-picker-row",
|
||||
});
|
||||
row.createSpan({
|
||||
cls: "chess-study-inline-picker-label",
|
||||
text: "Game",
|
||||
});
|
||||
const select = row.createEl("select", {
|
||||
cls: "chess-study-inline-picker-select",
|
||||
});
|
||||
select.createEl("option", { value: PLACEHOLDER, text: "Pick a game…" });
|
||||
for (const g of games) {
|
||||
select.createEl("option", {
|
||||
value: g.id,
|
||||
text: gameLabel(g.gameNumber, g.white, g.black, g.result),
|
||||
});
|
||||
}
|
||||
select.value =
|
||||
currentGame && currentGame.matchSlug === matchSlug
|
||||
? currentGame.id
|
||||
: PLACEHOLDER;
|
||||
select.addEventListener("change", () => {
|
||||
const v = select.value;
|
||||
opts.onChange({
|
||||
wccgame: v === PLACEHOLDER ? "" : v,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
renderGameRow(initialMatchSlug ?? null);
|
||||
|
||||
matchSelect.addEventListener("change", () => {
|
||||
const v = matchSelect.value;
|
||||
if (v === PLACEHOLDER) {
|
||||
renderGameRow(null);
|
||||
opts.onChange({ wccgame: "" });
|
||||
return;
|
||||
}
|
||||
renderGameRow(v);
|
||||
opts.onChange({ wccgame: "" });
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Helpers ------------------------------------------------------------- */
|
||||
|
||||
function gameLabel(
|
||||
gameNumber: number,
|
||||
white: string,
|
||||
black: string,
|
||||
result: string
|
||||
): string {
|
||||
const display = result === "1/2-1/2" ? "½-½" : result;
|
||||
return `Game ${gameNumber} — ${shortName(white)} vs ${shortName(black)} (${display})`;
|
||||
}
|
||||
|
||||
/** Trim 'Lastname, Firstname' / 'Lastname,F' to just the family name. */
|
||||
function shortName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
const comma = trimmed.indexOf(",");
|
||||
return comma > 0 ? trimmed.slice(0, comma) : trimmed;
|
||||
}
|
||||
|
||||
function detectCategory(opts: InlinePickerOptions): Category | null {
|
||||
if (opts.currentEndgame) return "endgames";
|
||||
if (opts.currentWccGame) return "wcc";
|
||||
if (opts.currentOpening) return "openings";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function findOpening(name: string | undefined): Opening | null {
|
||||
if (!name) return null;
|
||||
const target = normalize(name);
|
||||
return (
|
||||
OPENINGS.find((o) => {
|
||||
if (normalize(o.name) === target) return true;
|
||||
return o.aliases?.some((a) => normalize(a) === target) ?? false;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function findVariation(
|
||||
opening: Opening,
|
||||
name: string | undefined
|
||||
): OpeningVariation | null {
|
||||
if (!name) return null;
|
||||
const target = normalize(name);
|
||||
return (
|
||||
(opening.variations ?? []).find((v) => normalize(v.name) === target) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function findEndgameByIdOrName(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const target = normalize(value);
|
||||
const hit = ENDGAMES.find(
|
||||
(e) => normalize(e.id) === target || normalize(e.name) === target
|
||||
);
|
||||
return hit?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local light lookup so we don't need to import the public WCC helper just
|
||||
* to find a single game (it would also pull all WCC data into this bundle
|
||||
* for callers that don't need it — though picker is the only caller for
|
||||
* now).
|
||||
*/
|
||||
function findWccGameByIdLocal(
|
||||
id: string
|
||||
): { id: string; matchSlug: string } | null {
|
||||
const target = id.trim().toLowerCase();
|
||||
for (const m of listWccMatches()) {
|
||||
const games = listWccGamesForMatch(m.matchSlug);
|
||||
const hit = games.find((g) => g.id.toLowerCase() === target);
|
||||
if (hit) return { id: hit.id, matchSlug: hit.matchSlug };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
169
src/ui/lines-panel.ts
Normal file
169
src/ui/lines-panel.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type {
|
||||
ExplorerMove,
|
||||
ExplorerResult,
|
||||
} from "../chess/explorer";
|
||||
import type { ExplorerSource } from "../types";
|
||||
|
||||
export type LinesPanelState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "empty"; source: ExplorerSource }
|
||||
| {
|
||||
kind: "ready";
|
||||
result: ExplorerResult;
|
||||
fullMoveNumber: number;
|
||||
turn: "w" | "b";
|
||||
maxLines: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the "Lines" panel — a list of candidate next moves with stacked
|
||||
* win/draw/loss percentage bars. Designed to be re-called whenever the
|
||||
* underlying state changes; it owns the contents of `host` and rebuilds
|
||||
* them in place.
|
||||
*/
|
||||
export function renderLinesPanel(
|
||||
host: HTMLElement,
|
||||
state: LinesPanelState
|
||||
): void {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-lines-panel");
|
||||
|
||||
const header = host.createDiv({ cls: "chess-study-lines-header" });
|
||||
header.createSpan({ cls: "chess-study-lines-title", text: "Lines" });
|
||||
|
||||
switch (state.kind) {
|
||||
case "idle":
|
||||
host.createDiv({
|
||||
cls: "chess-study-lines-status",
|
||||
text: "Opening explorer disabled.",
|
||||
});
|
||||
return;
|
||||
case "loading":
|
||||
host.createDiv({
|
||||
cls: "chess-study-lines-status",
|
||||
text: "Loading lines…",
|
||||
});
|
||||
return;
|
||||
case "error":
|
||||
host.createDiv({
|
||||
cls: "chess-study-lines-status error",
|
||||
text: `Could not load explorer: ${state.message}`,
|
||||
});
|
||||
return;
|
||||
case "empty":
|
||||
host.createDiv({
|
||||
cls: "chess-study-lines-status",
|
||||
text: `No ${state.source === "masters" ? "master" : "Lichess"} games at this position.`,
|
||||
});
|
||||
return;
|
||||
case "ready":
|
||||
renderReady(host, state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function renderReady(
|
||||
host: HTMLElement,
|
||||
state: Extract<LinesPanelState, { kind: "ready" }>
|
||||
): void {
|
||||
const { result, fullMoveNumber, turn, maxLines } = state;
|
||||
|
||||
const meta = host.createDiv({ cls: "chess-study-lines-meta" });
|
||||
meta.createSpan({
|
||||
text: `${formatGames(result.totalGames)} games · ${
|
||||
result.source === "masters" ? "Masters" : "Lichess"
|
||||
}`,
|
||||
});
|
||||
|
||||
if (result.moves.length === 0) {
|
||||
host.createDiv({
|
||||
cls: "chess-study-lines-status",
|
||||
text: "No book moves at this position.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const table = host.createDiv({ cls: "chess-study-lines-table" });
|
||||
const moves = result.moves.slice(0, maxLines);
|
||||
|
||||
for (const move of moves) {
|
||||
const row = table.createDiv({ cls: "chess-study-lines-row" });
|
||||
|
||||
row.createSpan({
|
||||
cls: "chess-study-lines-label",
|
||||
text: formatMoveLabel(move.san, fullMoveNumber, turn),
|
||||
});
|
||||
|
||||
buildBar(row, move);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the move label as standard PGN-ish notation:
|
||||
* white to move on move 3 -> "3. Nc3"
|
||||
* black to move on move 3 -> "3...c5"
|
||||
*/
|
||||
function formatMoveLabel(
|
||||
san: string,
|
||||
fullMoveNumber: number,
|
||||
turn: "w" | "b"
|
||||
): string {
|
||||
return turn === "w"
|
||||
? `${fullMoveNumber}. ${san}`
|
||||
: `${fullMoveNumber}…${san}`;
|
||||
}
|
||||
|
||||
function formatGames(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function buildBar(row: HTMLElement, move: ExplorerMove): void {
|
||||
const bar = row.createDiv({ cls: "chess-study-lines-bar" });
|
||||
|
||||
const total = move.total || 1;
|
||||
const whitePct = (move.white / total) * 100;
|
||||
const drawsPct = (move.draws / total) * 100;
|
||||
const blackPct = (move.black / total) * 100;
|
||||
|
||||
makeSegment(bar, "white", whitePct);
|
||||
makeSegment(bar, "draws", drawsPct);
|
||||
makeSegment(bar, "black", blackPct);
|
||||
}
|
||||
|
||||
function makeSegment(
|
||||
bar: HTMLElement,
|
||||
kind: "white" | "draws" | "black",
|
||||
pct: number
|
||||
): void {
|
||||
const seg = bar.createDiv({
|
||||
cls: `chess-study-lines-seg seg-${kind}`,
|
||||
});
|
||||
seg.style.width = `${pct.toFixed(2)}%`;
|
||||
if (pct >= 8) {
|
||||
seg.createSpan({
|
||||
cls: "chess-study-lines-seg-label",
|
||||
text: `${formatPct(pct)}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatPct(pct: number): string {
|
||||
if (pct >= 100) return "100";
|
||||
if (pct >= 10) return pct.toFixed(0);
|
||||
return pct.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the full-move number and side-to-move out of a FEN string. Used by
|
||||
* the chess-block view to label candidate moves correctly.
|
||||
*/
|
||||
export function fenInfo(fen: string): { fullMoveNumber: number; turn: "w" | "b" } {
|
||||
const parts = fen.split(/\s+/);
|
||||
const turn = (parts[1] ?? "w") === "b" ? "b" : "w";
|
||||
const fullMoveNumber = parseInt(parts[5] ?? "1", 10) || 1;
|
||||
return { fullMoveNumber, turn };
|
||||
}
|
||||
86
src/ui/move-list.ts
Normal file
86
src/ui/move-list.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { PositionStep } from "../chess/engine";
|
||||
|
||||
export interface MoveListOptions {
|
||||
/** All position steps including index 0 (the start position). */
|
||||
steps: PositionStep[];
|
||||
/** Currently active step index (0 = start, 1 = after white's first move...). */
|
||||
activeIndex: number;
|
||||
/** Called when the user clicks a half-move; arg is the resulting step index. */
|
||||
onSelect: (stepIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a clean two-column move list (white | black). Step 0 (the start
|
||||
* position) is *not* rendered here — it's reachable via the "Start position"
|
||||
* button under the board, which keeps the move list strictly about moves.
|
||||
*/
|
||||
export function renderMoveList(host: HTMLElement, opts: MoveListOptions): void {
|
||||
host.empty();
|
||||
host.classList.add("chess-study-moves");
|
||||
|
||||
const moves = opts.steps.slice(1);
|
||||
|
||||
const table = host.createDiv({ cls: "chess-study-move-table" });
|
||||
const header = table.createDiv({ cls: "chess-study-move-row header" });
|
||||
header.createSpan({ cls: "chess-study-move-num", text: "#" });
|
||||
header.createSpan({ cls: "chess-study-move-cell header", text: "White" });
|
||||
header.createSpan({ cls: "chess-study-move-cell header", text: "Black" });
|
||||
|
||||
if (moves.length === 0) {
|
||||
host.createDiv({
|
||||
cls: "chess-study-moves-empty",
|
||||
text: "No moves yet.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
let moveNumber = 1;
|
||||
while (i < moves.length) {
|
||||
const row = table.createDiv({ cls: "chess-study-move-row" });
|
||||
row.createSpan({
|
||||
cls: "chess-study-move-num",
|
||||
text: `${moveNumber}.`,
|
||||
});
|
||||
|
||||
const whiteMove = moves[i];
|
||||
if (whiteMove) {
|
||||
renderCell(row, whiteMove.san ?? "", i + 1, opts);
|
||||
i += 1;
|
||||
} else {
|
||||
row.createSpan({ cls: "chess-study-move-cell empty" });
|
||||
}
|
||||
|
||||
const blackMove = moves[i];
|
||||
if (blackMove) {
|
||||
renderCell(row, blackMove.san ?? "", i + 1, opts);
|
||||
i += 1;
|
||||
} else {
|
||||
row.createSpan({ cls: "chess-study-move-cell empty" });
|
||||
}
|
||||
|
||||
moveNumber += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCell(
|
||||
row: HTMLElement,
|
||||
san: string,
|
||||
stepIndex: number,
|
||||
opts: MoveListOptions
|
||||
): void {
|
||||
const cell = row.createSpan({
|
||||
cls: "chess-study-move-cell",
|
||||
text: san,
|
||||
});
|
||||
cell.setAttribute("role", "button");
|
||||
cell.setAttribute("tabindex", "0");
|
||||
if (stepIndex === opts.activeIndex) cell.addClass("active");
|
||||
cell.addEventListener("click", () => opts.onSelect(stepIndex));
|
||||
cell.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
opts.onSelect(stepIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
107
src/ui/opening-picker.ts
Normal file
107
src/ui/opening-picker.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { App, FuzzySuggestModal } from "obsidian";
|
||||
import { OPENINGS } from "../chess/openings";
|
||||
import type { Opening, OpeningVariation } from "../types";
|
||||
|
||||
/** Result of a completed pick: an opening, optionally narrowed to a variation. */
|
||||
export interface OpeningPickResult {
|
||||
openingName: string;
|
||||
variationName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step opening picker:
|
||||
* 1. {@link OpeningPickerModal} — fuzzy-search the ~30 top-level openings.
|
||||
* 2. {@link VariationPickerModal} — if the chosen opening has variations,
|
||||
* pick one (or "No variation — just the opening").
|
||||
*
|
||||
* Splitting the pick into two stages keeps each list short and lets users
|
||||
* find what they want even when they can't spell the exact name, since the
|
||||
* second list is filtered to variations of one specific opening.
|
||||
*/
|
||||
export class OpeningPickerModal extends FuzzySuggestModal<Opening> {
|
||||
private readonly app_: App;
|
||||
private readonly onChoose: (result: OpeningPickResult) => void;
|
||||
|
||||
constructor(app: App, onChoose: (result: OpeningPickResult) => void) {
|
||||
super(app);
|
||||
this.app_ = app;
|
||||
this.onChoose = onChoose;
|
||||
this.setPlaceholder("Search openings");
|
||||
}
|
||||
|
||||
getItems(): Opening[] {
|
||||
return OPENINGS;
|
||||
}
|
||||
|
||||
getItemText(item: Opening): string {
|
||||
const aliases = item.aliases?.length
|
||||
? ` (${item.aliases.join(", ")})`
|
||||
: "";
|
||||
return `${item.name}${aliases}`;
|
||||
}
|
||||
|
||||
onChooseItem(item: Opening): void {
|
||||
const variations = item.variations ?? [];
|
||||
if (variations.length === 0) {
|
||||
this.onChoose({ openingName: item.name });
|
||||
return;
|
||||
}
|
||||
new VariationPickerModal(this.app_, item, this.onChoose).open();
|
||||
}
|
||||
}
|
||||
|
||||
interface VariationOption {
|
||||
/** Underlying variation, or undefined for the "no variation" sentinel. */
|
||||
variation?: OpeningVariation;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second step of the opening picker: shows variations for one opening, plus
|
||||
* a sentinel "No variation — just the opening" entry at the top.
|
||||
*/
|
||||
class VariationPickerModal extends FuzzySuggestModal<VariationOption> {
|
||||
private readonly opening: Opening;
|
||||
private readonly onChoose: (result: OpeningPickResult) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
opening: Opening,
|
||||
onChoose: (result: OpeningPickResult) => void
|
||||
) {
|
||||
super(app);
|
||||
this.opening = opening;
|
||||
this.onChoose = onChoose;
|
||||
this.setPlaceholder(`Search variations of ${opening.name}`);
|
||||
}
|
||||
|
||||
getItems(): VariationOption[] {
|
||||
const out: VariationOption[] = [
|
||||
{
|
||||
displayName: "No variation — just the opening",
|
||||
},
|
||||
];
|
||||
for (const v of this.opening.variations ?? []) {
|
||||
out.push({
|
||||
variation: v,
|
||||
displayName: v.name,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
getItemText(item: VariationOption): string {
|
||||
return item.displayName;
|
||||
}
|
||||
|
||||
onChooseItem(item: VariationOption): void {
|
||||
if (!item.variation) {
|
||||
this.onChoose({ openingName: this.opening.name });
|
||||
return;
|
||||
}
|
||||
this.onChoose({
|
||||
openingName: this.opening.name,
|
||||
variationName: item.variation.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
401
src/ui/openings-quiz.ts
Normal file
401
src/ui/openings-quiz.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { App, Modal, setIcon } from "obsidian";
|
||||
import { Chess } from "chess.js";
|
||||
import type { CaissaSettings, Opening, OpeningVariation } from "../types";
|
||||
import { OPENINGS } from "../chess/openings";
|
||||
import { renderBoard } from "./board-renderer";
|
||||
|
||||
interface QuizPosition {
|
||||
opening: Opening;
|
||||
variation: OpeningVariation | null;
|
||||
/** SAN tokens for the full line. */
|
||||
moves: string[];
|
||||
/** How many moves have already been played; user is asked for `moves[depth]`. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface QuizState {
|
||||
score: number;
|
||||
asked: number;
|
||||
streak: number;
|
||||
bestStreak: number;
|
||||
answered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Rolling openings quiz" — a self-contained modal that endlessly serves up
|
||||
* positions from the bundled opening repertoire and asks the user for the
|
||||
* next book move.
|
||||
*
|
||||
* Design notes:
|
||||
*
|
||||
* - Positions are sampled from the union of every opening + every variation
|
||||
* in {@link OPENINGS}, so deeper variations get more questions per round.
|
||||
* - We keep a tiny `recent` window so the same exact (opening, variation,
|
||||
* depth) tuple doesn't get asked twice in a row.
|
||||
* - Move validation goes through chess.js so we accept any legal SAN
|
||||
* spelling (`Nf3`, `Ngf3`, `N1f3`, etc.) — but only the *exact* expected
|
||||
* SAN from the repertoire counts as the right answer.
|
||||
* - Score is per-session only — never persisted. The point is reps, not
|
||||
* a leaderboard.
|
||||
*/
|
||||
export class OpeningsQuizModal extends Modal {
|
||||
private settings: CaissaSettings;
|
||||
private current: QuizPosition | null = null;
|
||||
private state: QuizState = {
|
||||
score: 0,
|
||||
asked: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
answered: false,
|
||||
};
|
||||
private recent: string[] = [];
|
||||
private boardHost!: HTMLElement;
|
||||
private promptEl!: HTMLElement;
|
||||
private feedbackEl!: HTMLElement;
|
||||
private revealEl!: HTMLElement;
|
||||
private input!: HTMLInputElement;
|
||||
private submitBtn!: HTMLButtonElement;
|
||||
private nextBtn!: HTMLButtonElement;
|
||||
private scoreEl!: HTMLElement;
|
||||
|
||||
constructor(app: App, settings: CaissaSettings) {
|
||||
super(app);
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.modalEl.addClass("chess-study-quiz-modal");
|
||||
this.contentEl.empty();
|
||||
|
||||
this.contentEl.createEl("h2", { text: "Openings quiz" });
|
||||
this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-help",
|
||||
text:
|
||||
"A position from a known opening will appear. Type the next book move (e.g. e4, Nf3, O-O) and press Enter.",
|
||||
});
|
||||
|
||||
this.scoreEl = this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-score",
|
||||
});
|
||||
|
||||
this.boardHost = this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-board",
|
||||
});
|
||||
|
||||
this.promptEl = this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-prompt",
|
||||
});
|
||||
|
||||
const inputRow = this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-input-row",
|
||||
});
|
||||
this.input = inputRow.createEl("input", {
|
||||
type: "text",
|
||||
cls: "chess-study-quiz-input",
|
||||
attr: {
|
||||
placeholder: "Your move",
|
||||
autocomplete: "off",
|
||||
spellcheck: "false",
|
||||
},
|
||||
});
|
||||
this.input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
if (this.state.answered) {
|
||||
this.next();
|
||||
} else {
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.submitBtn = inputRow.createEl("button", {
|
||||
cls: "mod-cta chess-study-quiz-submit",
|
||||
text: "Submit",
|
||||
});
|
||||
this.submitBtn.addEventListener("click", () => this.submit());
|
||||
|
||||
this.nextBtn = inputRow.createEl("button", {
|
||||
cls: "chess-study-quiz-next is-hidden",
|
||||
text: "Next position",
|
||||
});
|
||||
this.nextBtn.addEventListener("click", () => this.next());
|
||||
|
||||
this.feedbackEl = this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-feedback",
|
||||
});
|
||||
this.revealEl = this.contentEl.createDiv({
|
||||
cls: "chess-study-quiz-reveal",
|
||||
});
|
||||
|
||||
this.next();
|
||||
}
|
||||
|
||||
private next(): void {
|
||||
this.state.answered = false;
|
||||
this.feedbackEl.empty();
|
||||
this.feedbackEl.removeClass("is-correct");
|
||||
this.feedbackEl.removeClass("is-incorrect");
|
||||
this.revealEl.empty();
|
||||
this.input.disabled = false;
|
||||
this.input.value = "";
|
||||
this.submitBtn.removeClass("is-hidden");
|
||||
this.nextBtn.addClass("is-hidden");
|
||||
|
||||
this.current = pickQuizPosition(this.recent);
|
||||
this.recordRecent(this.current);
|
||||
|
||||
const fen = computeFenAtDepth(this.current.moves, this.current.depth);
|
||||
const sideToMove =
|
||||
fen.split(" ")[1] === "b" ? "Black" : "White";
|
||||
const moveNumber = Math.floor(this.current.depth / 2) + 1;
|
||||
const moveLabel =
|
||||
sideToMove === "White"
|
||||
? `${moveNumber}.`
|
||||
: `${moveNumber}…`;
|
||||
|
||||
renderBoard(this.boardHost, {
|
||||
fen,
|
||||
orientation: sideToMove === "White" ? "white" : "black",
|
||||
pieceSet: this.settings.pieceSet,
|
||||
lightColor: this.settings.lightSquareColor,
|
||||
darkColor: this.settings.darkSquareColor,
|
||||
highlightColor: this.settings.lastMoveColor,
|
||||
coordinateColor: this.settings.coordinateColor,
|
||||
showCoordinates: this.settings.showCoordinates,
|
||||
});
|
||||
|
||||
this.promptEl.empty();
|
||||
this.promptEl.createSpan({
|
||||
cls: "chess-study-quiz-side",
|
||||
text: `${moveLabel} ${sideToMove} to move`,
|
||||
});
|
||||
this.promptEl.createSpan({
|
||||
cls: "chess-study-quiz-mystery",
|
||||
text: " · what's the book move?",
|
||||
});
|
||||
|
||||
this.input.focus();
|
||||
this.renderScore();
|
||||
}
|
||||
|
||||
private submit(): void {
|
||||
if (!this.current || this.state.answered) return;
|
||||
|
||||
const raw = this.input.value.trim();
|
||||
if (!raw) {
|
||||
this.input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = this.current.moves[this.current.depth];
|
||||
if (!expected) return;
|
||||
|
||||
const fen = computeFenAtDepth(this.current.moves, this.current.depth);
|
||||
const verdict = checkAnswer(fen, raw, expected);
|
||||
|
||||
this.state.answered = true;
|
||||
this.state.asked++;
|
||||
this.input.disabled = true;
|
||||
this.submitBtn.addClass("is-hidden");
|
||||
this.nextBtn.removeClass("is-hidden");
|
||||
this.nextBtn.focus();
|
||||
|
||||
if (verdict.correct) {
|
||||
this.state.score++;
|
||||
this.state.streak++;
|
||||
if (this.state.streak > this.state.bestStreak) {
|
||||
this.state.bestStreak = this.state.streak;
|
||||
}
|
||||
this.feedbackEl.addClass("is-correct");
|
||||
const playedSan = verdict.playedSan ?? expected;
|
||||
this.feedbackEl.createSpan({ text: `Correct — ${playedSan}` });
|
||||
} else {
|
||||
this.state.streak = 0;
|
||||
this.feedbackEl.addClass("is-incorrect");
|
||||
if (verdict.playedSan) {
|
||||
this.feedbackEl.createSpan({
|
||||
text: `Not the book move. You played ${verdict.playedSan}; expected ${expected}.`,
|
||||
});
|
||||
} else {
|
||||
this.feedbackEl.createSpan({
|
||||
text: `Illegal move "${raw}". Expected ${expected}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const labelParts: string[] = [this.current.opening.name];
|
||||
if (this.current.variation) {
|
||||
labelParts.push(this.current.variation.name);
|
||||
}
|
||||
this.revealEl.createSpan({
|
||||
cls: "chess-study-quiz-reveal-label",
|
||||
text: `From: ${labelParts.join(" — ")}`,
|
||||
});
|
||||
|
||||
this.renderScore();
|
||||
}
|
||||
|
||||
private renderScore(): void {
|
||||
this.scoreEl.empty();
|
||||
const span = this.scoreEl.createSpan();
|
||||
setIcon(span, "trophy");
|
||||
this.scoreEl.createSpan({
|
||||
text: ` ${this.state.score} / ${this.state.asked}`,
|
||||
});
|
||||
if (this.state.bestStreak > 0) {
|
||||
this.scoreEl.createSpan({
|
||||
cls: "chess-study-quiz-streak",
|
||||
text: ` · streak ${this.state.streak} (best ${this.state.bestStreak})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private recordRecent(pos: QuizPosition): void {
|
||||
const key = quizKey(pos);
|
||||
this.recent.push(key);
|
||||
if (this.recent.length > 8) this.recent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
interface AnswerVerdict {
|
||||
correct: boolean;
|
||||
playedSan?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user's input against the expected SAN. We rely on chess.js
|
||||
* to canonicalize SAN, which both makes the comparison robust to spelling
|
||||
* variants (`Nf3` vs `Ngf3` when only one knight reaches f3) and rejects
|
||||
* outright illegal moves with a helpful failure mode.
|
||||
*/
|
||||
function checkAnswer(
|
||||
fen: string,
|
||||
rawInput: string,
|
||||
expectedSan: string
|
||||
): AnswerVerdict {
|
||||
const cleaned = rawInput
|
||||
.replace(/^\d+\.\s*/, "")
|
||||
.replace(/^\d+\.\.\.?\s*/, "")
|
||||
.trim();
|
||||
|
||||
const chess = new Chess(fen);
|
||||
let played: ReturnType<typeof chess.move> | null = null;
|
||||
try {
|
||||
played = chess.move(cleaned);
|
||||
} catch {
|
||||
// Fall through; played stays null.
|
||||
}
|
||||
|
||||
if (!played) {
|
||||
return { correct: false };
|
||||
}
|
||||
|
||||
// Compare canonical SAN — chess.js will return e.g. "Nf3" even if user
|
||||
// typed "Ngf3", so we re-derive the expected canonical SAN by playing
|
||||
// it on a fresh board and comparing the two.
|
||||
const expectedChess = new Chess(fen);
|
||||
let expectedMove: ReturnType<typeof expectedChess.move> | null = null;
|
||||
try {
|
||||
expectedMove = expectedChess.move(expectedSan);
|
||||
} catch {
|
||||
// shouldn't happen — repertoire moves are pre-validated
|
||||
}
|
||||
const expectedCanonical = expectedMove?.san ?? expectedSan;
|
||||
|
||||
return {
|
||||
correct: played.san === expectedCanonical,
|
||||
playedSan: played.san,
|
||||
};
|
||||
}
|
||||
|
||||
function quizKey(pos: QuizPosition): string {
|
||||
const v = pos.variation ? pos.variation.name : "";
|
||||
return `${pos.opening.name}|${v}|${pos.depth}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat catalog of (opening, variation?, moves) entries we sample
|
||||
* from. Done lazily and cached on first use because the opening list is
|
||||
* stable for the life of the plugin.
|
||||
*/
|
||||
let CATALOG: Array<{
|
||||
opening: Opening;
|
||||
variation: OpeningVariation | null;
|
||||
moves: string[];
|
||||
}> | null = null;
|
||||
|
||||
function getCatalog(): Array<{
|
||||
opening: Opening;
|
||||
variation: OpeningVariation | null;
|
||||
moves: string[];
|
||||
}> {
|
||||
if (CATALOG) return CATALOG;
|
||||
const out: Array<{
|
||||
opening: Opening;
|
||||
variation: OpeningVariation | null;
|
||||
moves: string[];
|
||||
}> = [];
|
||||
for (const op of OPENINGS) {
|
||||
const baseMoves = tokenize(op.moves);
|
||||
if (baseMoves.length > 0) {
|
||||
out.push({ opening: op, variation: null, moves: baseMoves });
|
||||
}
|
||||
for (const v of op.variations ?? []) {
|
||||
const m = tokenize(v.moves);
|
||||
if (m.length > 0) {
|
||||
out.push({ opening: op, variation: v, moves: m });
|
||||
}
|
||||
}
|
||||
}
|
||||
CATALOG = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickQuizPosition(recent: string[]): QuizPosition {
|
||||
const catalog = getCatalog();
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const entry = catalog[Math.floor(Math.random() * catalog.length)];
|
||||
if (!entry) continue;
|
||||
// Pick a depth in [0, moves.length-1] so we always have a "next" move.
|
||||
const depth = Math.floor(Math.random() * entry.moves.length);
|
||||
const candidate: QuizPosition = {
|
||||
opening: entry.opening,
|
||||
variation: entry.variation,
|
||||
moves: entry.moves,
|
||||
depth,
|
||||
};
|
||||
if (!recent.includes(quizKey(candidate))) return candidate;
|
||||
}
|
||||
// All recent — fall back to the last attempt regardless.
|
||||
const fallback = catalog[Math.floor(Math.random() * catalog.length)] ?? catalog[0];
|
||||
return {
|
||||
opening: fallback?.opening as Opening,
|
||||
variation: fallback?.variation ?? null,
|
||||
moves: fallback?.moves ?? [],
|
||||
depth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function computeFenAtDepth(moves: string[], depth: number): string {
|
||||
const chess = new Chess();
|
||||
for (let i = 0; i < depth; i++) {
|
||||
const san = moves[i];
|
||||
if (!san) break;
|
||||
try {
|
||||
chess.move(san);
|
||||
} catch {
|
||||
// Malformed repertoire entry — bail with whatever position we got.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chess.fen();
|
||||
}
|
||||
|
||||
function tokenize(moves: string): string[] {
|
||||
return moves
|
||||
.replace(/\b\d+\.(\.\.)?/g, " ")
|
||||
.split(/\s+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
442
src/ui/play-controller.ts
Normal file
442
src/ui/play-controller.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { Chess, type Square, type Move } from "chess.js";
|
||||
import { App, setIcon } from "obsidian";
|
||||
import type {
|
||||
CaissaSettings,
|
||||
ChessBlockConfig,
|
||||
Orientation,
|
||||
PieceColor,
|
||||
PieceType,
|
||||
} from "../types";
|
||||
import type { PositionStep } from "../chess/engine";
|
||||
import { getEngine } from "../chess/engine-worker";
|
||||
import { renderBoard } from "./board-renderer";
|
||||
import { renderMoveList } from "./move-list";
|
||||
import { computeCaptured, hasAnyCaptures } from "../utils/captured-pieces";
|
||||
import { renderCapturedTray } from "./captured-tray";
|
||||
import { PromotionPickerModal } from "./promotion-picker";
|
||||
|
||||
/**
|
||||
* Play-vs-Stockfish controller.
|
||||
*
|
||||
* Renders its own layout (board + side panel + controls) instead of
|
||||
* piggy-backing on the analysis-style stepper, because the lifecycle is
|
||||
* fundamentally different: the user is *making* moves, not just stepping
|
||||
* a pre-baked sequence. State machine:
|
||||
*
|
||||
* awaitingHuman ── clickFriendlyPiece ──► selected
|
||||
* selected ── clickLegalTarget ──► (apply move)
|
||||
* │
|
||||
* └─► awaitingEngine
|
||||
* awaitingEngine ── engine bestmove ──► (apply move) ──► awaitingHuman
|
||||
* (any) ── checkmate/draw ──► gameOver
|
||||
*
|
||||
* Promotion is handled inline: when a pawn move would land on the last
|
||||
* rank, the {@link PromotionPickerModal} prompts the user instead of
|
||||
* silently picking a queen.
|
||||
*/
|
||||
|
||||
export interface PlayControllerArgs {
|
||||
host: HTMLElement;
|
||||
startFen: string;
|
||||
humanColor: PieceColor;
|
||||
level: number;
|
||||
config: ChessBlockConfig;
|
||||
settings: CaissaSettings;
|
||||
app: App;
|
||||
}
|
||||
|
||||
type PlayState =
|
||||
| { kind: "awaitingHuman"; selected: Square | null }
|
||||
| { kind: "thinking" }
|
||||
| { kind: "gameOver"; reason: string };
|
||||
|
||||
export function renderPlayBlock(args: PlayControllerArgs): void {
|
||||
const { host, startFen, humanColor, level, config, settings, app } = args;
|
||||
host.empty();
|
||||
host.classList.add("chess-study-block");
|
||||
|
||||
const orientation: Orientation = humanColor === "w" ? "white" : "black";
|
||||
const pieceSet = config.pieces ?? settings.pieceSet;
|
||||
const lightColor = config.light ?? settings.lightSquareColor;
|
||||
const darkColor = config.dark ?? settings.darkSquareColor;
|
||||
const showCoords = config.coordinates ?? settings.showCoordinates;
|
||||
const showMoves = config.showMoves ?? true;
|
||||
const showCaptured = config.captured ?? settings.showCapturedPieces;
|
||||
const size = config.size ?? "medium";
|
||||
const skill = Math.max(0, Math.min(level, 20));
|
||||
|
||||
if (config.title) {
|
||||
host.createDiv({ cls: "chess-study-title", text: config.title });
|
||||
}
|
||||
|
||||
const layout = host.createDiv({
|
||||
cls: `chess-study-layout size-${size}${showMoves ? "" : " no-moves"}`,
|
||||
attr: {
|
||||
tabindex: "0",
|
||||
role: "group",
|
||||
"aria-label": `Play vs Stockfish (skill ${skill}). Click a piece, then click its destination.`,
|
||||
},
|
||||
});
|
||||
|
||||
const boardCol = layout.createDiv({ cls: "chess-study-board-col" });
|
||||
const topTrayHost = boardCol.createDiv({
|
||||
cls: "chess-study-captured-wrap chess-study-captured-top",
|
||||
});
|
||||
const boardHost = boardCol.createDiv({ cls: "chess-study-board-wrap" });
|
||||
const bottomTrayHost = boardCol.createDiv({
|
||||
cls: "chess-study-captured-wrap chess-study-captured-bottom",
|
||||
});
|
||||
const statusHost = boardCol.createDiv({ cls: "chess-study-play-status" });
|
||||
const controlsHost = boardCol.createDiv({
|
||||
cls: "chess-study-controls chess-study-play-controls",
|
||||
});
|
||||
|
||||
const sideCol = showMoves
|
||||
? layout.createDiv({ cls: "chess-study-side-col" })
|
||||
: null;
|
||||
const movesHost =
|
||||
sideCol?.createDiv({ cls: "chess-study-moves-wrap" }) ?? null;
|
||||
|
||||
let chess = new Chess(startFen);
|
||||
let state: PlayState = { kind: "awaitingHuman", selected: null };
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const buildSteps = (): PositionStep[] => {
|
||||
const steps: PositionStep[] = [{ fen: startFen }];
|
||||
const replay = new Chess(startFen);
|
||||
const history = chess.history({ verbose: true });
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const m = history[i];
|
||||
if (!m) continue;
|
||||
const played = replay.move(m.san);
|
||||
steps.push({
|
||||
fen: replay.fen(),
|
||||
san: played.san,
|
||||
from: played.from,
|
||||
to: played.to,
|
||||
color: played.color,
|
||||
moveNumber: Math.floor(i / 2) + 1,
|
||||
captured: played.captured as PieceType | undefined,
|
||||
promotion: played.promotion as PieceType | undefined,
|
||||
});
|
||||
}
|
||||
return steps;
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const lastMove = chess.history({ verbose: true }).at(-1);
|
||||
const selected =
|
||||
state.kind === "awaitingHuman" ? state.selected : null;
|
||||
const legalTargets = selected
|
||||
? chess
|
||||
.moves({ square: selected, verbose: true })
|
||||
.map((m: Move) => m.to)
|
||||
: [];
|
||||
|
||||
renderBoard(boardHost, {
|
||||
fen: chess.fen(),
|
||||
orientation,
|
||||
pieceSet,
|
||||
lightColor,
|
||||
darkColor,
|
||||
highlightColor: settings.lastMoveColor,
|
||||
coordinateColor: settings.coordinateColor,
|
||||
showCoordinates: showCoords,
|
||||
from: lastMove?.from,
|
||||
to: lastMove?.to,
|
||||
selectedSquare: selected ?? undefined,
|
||||
legalTargets,
|
||||
onSquareClick: (sq) => onSquareClick(sq as Square),
|
||||
});
|
||||
drawCapturedTrays();
|
||||
drawMoveList();
|
||||
drawStatus();
|
||||
};
|
||||
|
||||
const drawCapturedTrays = () => {
|
||||
if (!showCaptured) {
|
||||
topTrayHost.empty();
|
||||
bottomTrayHost.empty();
|
||||
topTrayHost.classList.add("is-hidden");
|
||||
bottomTrayHost.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
const steps = buildSteps();
|
||||
const totals = computeCaptured(steps, steps.length - 1);
|
||||
if (!hasAnyCaptures(totals)) {
|
||||
topTrayHost.empty();
|
||||
bottomTrayHost.empty();
|
||||
topTrayHost.classList.add("is-hidden");
|
||||
bottomTrayHost.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
topTrayHost.classList.remove("is-hidden");
|
||||
bottomTrayHost.classList.remove("is-hidden");
|
||||
const bottomColor: PieceColor = humanColor;
|
||||
const topColor: PieceColor = humanColor === "w" ? "b" : "w";
|
||||
renderCapturedTray(topTrayHost, {
|
||||
color: topColor,
|
||||
totals,
|
||||
pieceSet,
|
||||
showAdvantage: true,
|
||||
});
|
||||
renderCapturedTray(bottomTrayHost, {
|
||||
color: bottomColor,
|
||||
totals,
|
||||
pieceSet,
|
||||
showAdvantage: true,
|
||||
});
|
||||
};
|
||||
|
||||
const drawMoveList = () => {
|
||||
if (!movesHost) return;
|
||||
const steps = buildSteps();
|
||||
renderMoveList(movesHost, {
|
||||
steps,
|
||||
activeIndex: steps.length - 1,
|
||||
onSelect: () => {
|
||||
/* clicking moves doesn't seek mid-game (would diverge
|
||||
from the current position); kept passive on purpose. */
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const drawStatus = () => {
|
||||
statusHost.empty();
|
||||
const turn = chess.turn();
|
||||
if (state.kind === "gameOver") {
|
||||
statusHost.createSpan({
|
||||
cls: "chess-study-play-status-text is-gameover",
|
||||
text: state.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.kind === "thinking") {
|
||||
statusHost.createSpan({
|
||||
cls: "chess-study-play-status-text is-thinking",
|
||||
text: `Stockfish (skill ${skill}) is thinking…`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const youMove = turn === humanColor;
|
||||
const inCheck = chess.inCheck();
|
||||
const text = youMove
|
||||
? inCheck
|
||||
? "Your move — you're in check."
|
||||
: "Your move."
|
||||
: "Engine to move.";
|
||||
statusHost.createSpan({
|
||||
cls: "chess-study-play-status-text",
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
const onSquareClick = (sq: Square) => {
|
||||
if (state.kind !== "awaitingHuman") return;
|
||||
if (chess.turn() !== humanColor) return;
|
||||
|
||||
const piece = chess.get(sq);
|
||||
// Selecting a friendly piece (or re-selecting) just changes which
|
||||
// square is "picked up". Clicking the same square again deselects.
|
||||
if (piece && piece.color === humanColor) {
|
||||
if (state.selected === sq) {
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
} else {
|
||||
state = { kind: "awaitingHuman", selected: sq };
|
||||
}
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise we're attempting a move from the current selection.
|
||||
const from = state.selected;
|
||||
if (!from) return;
|
||||
|
||||
const legal = chess.moves({ square: from, verbose: true });
|
||||
const candidates = legal.filter((m: Move) => m.to === sq);
|
||||
if (candidates.length === 0) {
|
||||
// Off-target click — deselect (matches Lichess behavior).
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
|
||||
const needsPromotion = candidates.some(
|
||||
(m: Move) => (m.promotion ?? "") !== ""
|
||||
);
|
||||
if (needsPromotion) {
|
||||
void promptPromotion(app, humanColor).then((piece) => {
|
||||
if (!piece) return;
|
||||
applyMove({ from, to: sq, promotion: piece });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
applyMove({ from, to: sq });
|
||||
};
|
||||
|
||||
const applyMove = (move: { from: Square; to: Square; promotion?: string }) => {
|
||||
try {
|
||||
chess.move(move);
|
||||
} catch {
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
draw();
|
||||
afterMove();
|
||||
};
|
||||
|
||||
const afterMove = () => {
|
||||
if (checkAndSetGameOver()) {
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (chess.turn() !== humanColor) {
|
||||
void requestEngineMove();
|
||||
}
|
||||
};
|
||||
|
||||
const requestEngineMove = async () => {
|
||||
state = { kind: "thinking" };
|
||||
draw();
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
try {
|
||||
const uci = await getEngine().findBestMove(chess.fen(), {
|
||||
skillLevel: skill,
|
||||
signal,
|
||||
});
|
||||
if (signal.aborted) return;
|
||||
if (!uci) {
|
||||
checkAndSetGameOver();
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
const from = uci.slice(0, 2) as Square;
|
||||
const to = uci.slice(2, 4) as Square;
|
||||
const promotion = uci.length >= 5 ? uci.slice(4, 5) : undefined;
|
||||
try {
|
||||
chess.move({ from, to, promotion });
|
||||
} catch {
|
||||
// Engine returned an illegal move — shouldn't happen, but
|
||||
// don't blow up the board if it does.
|
||||
}
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
checkAndSetGameOver();
|
||||
draw();
|
||||
} catch (err) {
|
||||
state = {
|
||||
kind: "gameOver",
|
||||
reason: `Engine error: ${(err as Error).message}`,
|
||||
};
|
||||
draw();
|
||||
}
|
||||
};
|
||||
|
||||
const checkAndSetGameOver = (): boolean => {
|
||||
if (chess.isCheckmate()) {
|
||||
const winner = chess.turn() === "w" ? "Black" : "White";
|
||||
state = {
|
||||
kind: "gameOver",
|
||||
reason: `Checkmate — ${winner} wins.`,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (chess.isStalemate()) {
|
||||
state = { kind: "gameOver", reason: "Stalemate. Draw." };
|
||||
return true;
|
||||
}
|
||||
if (chess.isThreefoldRepetition()) {
|
||||
state = {
|
||||
kind: "gameOver",
|
||||
reason: "Draw by threefold repetition.",
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (chess.isInsufficientMaterial()) {
|
||||
state = {
|
||||
kind: "gameOver",
|
||||
reason: "Draw by insufficient material.",
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (chess.isDraw()) {
|
||||
state = { kind: "gameOver", reason: "Draw (50-move rule)." };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const newGame = () => {
|
||||
abortController?.abort();
|
||||
chess = new Chess(startFen);
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
draw();
|
||||
// If the human plays black, the engine moves first.
|
||||
if (chess.turn() !== humanColor) {
|
||||
void requestEngineMove();
|
||||
}
|
||||
};
|
||||
|
||||
const undoOnce = () => {
|
||||
// Roll back the last full pair (engine's reply + your move) so
|
||||
// the board returns to a position where you're on move again.
|
||||
if (state.kind === "thinking") return;
|
||||
abortController?.abort();
|
||||
const history = chess.history({ verbose: true });
|
||||
if (history.length === 0) return;
|
||||
const last = history[history.length - 1];
|
||||
const popN = last && last.color === humanColor ? 1 : 2;
|
||||
for (let i = 0; i < popN; i++) chess.undo();
|
||||
state = { kind: "awaitingHuman", selected: null };
|
||||
draw();
|
||||
};
|
||||
|
||||
const buildControls = () => {
|
||||
controlsHost.empty();
|
||||
const make = (
|
||||
label: string,
|
||||
icon: string,
|
||||
onClick: () => void
|
||||
) => {
|
||||
const btn = controlsHost.createEl("button", {
|
||||
cls: "chess-study-control",
|
||||
attr: { "aria-label": label, type: "button", title: label },
|
||||
});
|
||||
setIcon(btn, icon);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
};
|
||||
make("Undo your last move", "rotate-ccw", undoOnce);
|
||||
make("New game", "refresh-cw", newGame);
|
||||
};
|
||||
|
||||
buildControls();
|
||||
draw();
|
||||
|
||||
// If the human plays black, kick off the engine so it makes the
|
||||
// first move.
|
||||
if (chess.turn() !== humanColor) {
|
||||
void requestEngineMove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the promotion picker modal and resolve with the user's pick, or
|
||||
* `null` if they dismissed it (in which case we abandon the move).
|
||||
*/
|
||||
function promptPromotion(
|
||||
app: App,
|
||||
color: PieceColor
|
||||
): Promise<PieceType | null> {
|
||||
return new Promise<PieceType | null>((resolve) => {
|
||||
const modal = new PromotionPickerModal(app, color, (pick) =>
|
||||
resolve(pick)
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
94
src/ui/promotion-picker.ts
Normal file
94
src/ui/promotion-picker.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { App, Modal } from "obsidian";
|
||||
import type { PieceColor, PieceType } from "../types";
|
||||
import { createPieceNode, getPieceGlyph, type PieceKey } from "../chess/pieces";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const PROMOTION_PIECES: PieceType[] = ["q", "r", "b", "n"];
|
||||
|
||||
/**
|
||||
* Modal that shows the four promotion choices (Queen, Rook, Bishop, Knight)
|
||||
* as clickable piece glyphs. Closing the modal without picking is treated
|
||||
* as "cancel" — the caller abandons the move so the user can pick a
|
||||
* different from-square.
|
||||
*
|
||||
* Designed to feel similar to the Lichess promotion popover: pieces are
|
||||
* laid out left-to-right in descending value, and each is a big easy-to-tap
|
||||
* target so it works on touch.
|
||||
*/
|
||||
export class PromotionPickerModal extends Modal {
|
||||
private color: PieceColor;
|
||||
private onPick: (piece: PieceType | null) => void;
|
||||
private resolved = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
color: PieceColor,
|
||||
onPick: (piece: PieceType | null) => void
|
||||
) {
|
||||
super(app);
|
||||
this.color = color;
|
||||
this.onPick = onPick;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.modalEl.addClass("chess-study-promotion-modal");
|
||||
this.contentEl.empty();
|
||||
this.contentEl.createEl("h3", { text: "Promote to" });
|
||||
|
||||
const row = this.contentEl.createDiv({
|
||||
cls: "chess-study-promotion-row",
|
||||
});
|
||||
for (const type of PROMOTION_PIECES) {
|
||||
const btn = row.createEl("button", {
|
||||
cls: "chess-study-promotion-choice",
|
||||
attr: { type: "button", "aria-label": pieceLabel(type) },
|
||||
});
|
||||
renderChoiceGlyph(btn, this.color, type);
|
||||
btn.addEventListener("click", () => {
|
||||
this.resolved = true;
|
||||
this.onPick(type);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
if (!this.resolved) {
|
||||
this.onPick(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderChoiceGlyph(
|
||||
host: HTMLElement,
|
||||
color: PieceColor,
|
||||
type: PieceType
|
||||
): void {
|
||||
const key: PieceKey = `${color}${type}`;
|
||||
const node = createPieceNode("cburnett", key);
|
||||
if (node) {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 45 45");
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
svg.classList.add("chess-study-promotion-svg");
|
||||
svg.appendChild(node);
|
||||
host.appendChild(svg);
|
||||
return;
|
||||
}
|
||||
host.textContent = getPieceGlyph(key);
|
||||
}
|
||||
|
||||
function pieceLabel(type: PieceType): string {
|
||||
switch (type) {
|
||||
case "q":
|
||||
return "Queen";
|
||||
case "r":
|
||||
return "Rook";
|
||||
case "b":
|
||||
return "Bishop";
|
||||
case "n":
|
||||
return "Knight";
|
||||
default:
|
||||
return "Piece";
|
||||
}
|
||||
}
|
||||
71
src/ui/wcc-picker.ts
Normal file
71
src/ui/wcc-picker.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { App, FuzzySuggestModal } from "obsidian";
|
||||
import {
|
||||
listWccMatches,
|
||||
listWccGamesForMatch,
|
||||
type WccMatchData,
|
||||
type WccGameData,
|
||||
} from "../chess/wcc-games";
|
||||
|
||||
/**
|
||||
* Two-step WCC picker:
|
||||
* 1. {@link WccMatchPickerModal} — fuzzy-search by match (year + players).
|
||||
* 2. {@link WccGamePickerModal} — pick one of the bundled games for that match.
|
||||
*/
|
||||
export class WccMatchPickerModal extends FuzzySuggestModal<WccMatchData> {
|
||||
private readonly app_: App;
|
||||
private readonly onChoose: (game: WccGameData) => void;
|
||||
|
||||
constructor(app: App, onChoose: (game: WccGameData) => void) {
|
||||
super(app);
|
||||
this.app_ = app;
|
||||
this.onChoose = onChoose;
|
||||
this.setPlaceholder("Search world championship matches");
|
||||
}
|
||||
|
||||
getItems(): WccMatchData[] {
|
||||
return listWccMatches();
|
||||
}
|
||||
|
||||
getItemText(item: WccMatchData): string {
|
||||
return item.matchLabel;
|
||||
}
|
||||
|
||||
onChooseItem(item: WccMatchData): void {
|
||||
new WccGamePickerModal(this.app_, item, this.onChoose).open();
|
||||
}
|
||||
}
|
||||
|
||||
class WccGamePickerModal extends FuzzySuggestModal<WccGameData> {
|
||||
private readonly match: WccMatchData;
|
||||
private readonly onChoose: (game: WccGameData) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
match: WccMatchData,
|
||||
onChoose: (game: WccGameData) => void
|
||||
) {
|
||||
super(app);
|
||||
this.match = match;
|
||||
this.onChoose = onChoose;
|
||||
this.setPlaceholder(`Search games — ${match.matchLabel}`);
|
||||
}
|
||||
|
||||
getItems(): WccGameData[] {
|
||||
return listWccGamesForMatch(this.match.matchSlug);
|
||||
}
|
||||
|
||||
getItemText(item: WccGameData): string {
|
||||
const display = item.result === "1/2-1/2" ? "½-½" : item.result;
|
||||
return `Game ${item.gameNumber} — ${shortName(item.white)} vs ${shortName(item.black)} (${display})`;
|
||||
}
|
||||
|
||||
onChooseItem(item: WccGameData): void {
|
||||
this.onChoose(item);
|
||||
}
|
||||
}
|
||||
|
||||
function shortName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
const comma = trimmed.indexOf(",");
|
||||
return comma > 0 ? trimmed.slice(0, comma) : trimmed;
|
||||
}
|
||||
101
src/utils/annotations.ts
Normal file
101
src/utils/annotations.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Parse declarative arrow and square-highlight annotations out of the
|
||||
* `arrows:` and `highlights:` keys in a chess code block.
|
||||
*
|
||||
* Token grammar (whitespace-separated):
|
||||
*
|
||||
* arrow := <from-square><to-square>(-<color>)?
|
||||
* highlight := <square>(-<color>)?
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* arrows: e2e4 g1f3 d2d4-blue
|
||||
* highlights: d4 e4-red f5-yellow
|
||||
*
|
||||
* Colors may be a named preset (`green`, `red`, `yellow`, `blue`) or a
|
||||
* 3/6/8-character hex code without the leading `#` (so the `-` separator
|
||||
* stays unambiguous). Unknown colors fall back to green.
|
||||
*
|
||||
* Invalid tokens are silently dropped — annotations are a presentation
|
||||
* concern and should never break a board's render.
|
||||
*/
|
||||
|
||||
export interface ArrowSpec {
|
||||
from: string;
|
||||
to: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface HighlightSpec {
|
||||
square: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const NAMED_COLORS: Record<string, string> = {
|
||||
green: "rgba(85, 153, 51, 0.85)",
|
||||
red: "rgba(204, 51, 51, 0.85)",
|
||||
yellow: "rgba(232, 197, 49, 0.85)",
|
||||
blue: "rgba(0, 110, 184, 0.85)",
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR = NAMED_COLORS["green"] ?? "rgba(85, 153, 51, 0.85)";
|
||||
|
||||
const HEX_RE = /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/;
|
||||
|
||||
export function parseArrows(value: string | undefined): ArrowSpec[] {
|
||||
if (!value) return [];
|
||||
const out: ArrowSpec[] = [];
|
||||
for (const tok of value.split(/\s+/)) {
|
||||
if (!tok) continue;
|
||||
const { squarePart, color } = splitColor(tok);
|
||||
if (squarePart.length !== 4) continue;
|
||||
const from = squarePart.slice(0, 2).toLowerCase();
|
||||
const to = squarePart.slice(2, 4).toLowerCase();
|
||||
if (!isAlgebraicSquare(from) || !isAlgebraicSquare(to)) continue;
|
||||
if (from === to) continue;
|
||||
out.push({ from, to, color });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHighlights(value: string | undefined): HighlightSpec[] {
|
||||
if (!value) return [];
|
||||
const out: HighlightSpec[] = [];
|
||||
for (const tok of value.split(/\s+/)) {
|
||||
if (!tok) continue;
|
||||
const { squarePart, color } = splitColor(tok);
|
||||
const sq = squarePart.toLowerCase();
|
||||
if (!isAlgebraicSquare(sq)) continue;
|
||||
out.push({ square: sq, color });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function splitColor(tok: string): { squarePart: string; color: string } {
|
||||
const dashIdx = tok.indexOf("-");
|
||||
if (dashIdx < 0) {
|
||||
return { squarePart: tok, color: DEFAULT_COLOR };
|
||||
}
|
||||
const squarePart = tok.slice(0, dashIdx);
|
||||
const rawColor = tok.slice(dashIdx + 1).toLowerCase();
|
||||
return { squarePart, color: resolveColor(rawColor) };
|
||||
}
|
||||
|
||||
function resolveColor(name: string): string {
|
||||
const named = NAMED_COLORS[name];
|
||||
if (named) return named;
|
||||
if (HEX_RE.test(name)) return `#${name}`;
|
||||
return DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
function isAlgebraicSquare(sq: string): boolean {
|
||||
if (sq.length !== 2) return false;
|
||||
const file = sq.charCodeAt(0);
|
||||
const rank = sq.charCodeAt(1);
|
||||
return (
|
||||
file >= "a".charCodeAt(0) &&
|
||||
file <= "h".charCodeAt(0) &&
|
||||
rank >= "1".charCodeAt(0) &&
|
||||
rank <= "8".charCodeAt(0)
|
||||
);
|
||||
}
|
||||
136
src/utils/block-rewrite.ts
Normal file
136
src/utils/block-rewrite.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { App, MarkdownPostProcessorContext, Notice, TFile } from "obsidian";
|
||||
|
||||
export interface BlockRewriteUpdates {
|
||||
/** Set the `opening:` key. Pass empty string to remove it. */
|
||||
opening?: string;
|
||||
/** Set the `variation:` key. Pass empty string to remove it. */
|
||||
variation?: string;
|
||||
/** Set the `endgame:` key. Pass empty string to remove it. */
|
||||
endgame?: string;
|
||||
/** Set the `wccgame:` key. Pass empty string to remove it. */
|
||||
wccgame?: string;
|
||||
}
|
||||
|
||||
/** Keys whose lines we may rewrite or strip. Anything else is preserved verbatim. */
|
||||
const REWRITABLE_KEYS = new Set([
|
||||
"opening",
|
||||
"variation",
|
||||
"endgame",
|
||||
"wccgame",
|
||||
"wcc",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Rewrite the `chess` code block backed by `el` so that its `opening:` and/or
|
||||
* `variation:` lines reflect `updates`. All other body lines (style options,
|
||||
* comments, blanks) are preserved in their original order.
|
||||
*
|
||||
* Returns true on success, false if the block can't be located (e.g. inside a
|
||||
* callout, transcluded preview, or the source file has gone missing).
|
||||
*/
|
||||
export async function rewriteChessBlock(
|
||||
app: App,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
el: HTMLElement,
|
||||
updates: BlockRewriteUpdates
|
||||
): Promise<boolean> {
|
||||
const info = ctx.getSectionInfo(el);
|
||||
if (!info) {
|
||||
new Notice(
|
||||
"Couldn't locate this chess block in the source file. Try editing the block manually."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const file = app.vault.getAbstractFileByPath(ctx.sourcePath);
|
||||
if (!(file instanceof TFile)) {
|
||||
new Notice("Couldn't find the note for this chess block.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// `lineStart` is the opening fence (```chess), `lineEnd` is the closing fence (```).
|
||||
const bodyStart = info.lineStart + 1;
|
||||
const bodyEnd = info.lineEnd;
|
||||
|
||||
// Use vault.process so the read-modify-write is atomic against any
|
||||
// concurrent edits to the same file. If the boundaries no longer line up
|
||||
// (e.g. the user edited the file since the post-processor ran), we bail
|
||||
// out by returning the original contents unchanged.
|
||||
let aborted = false;
|
||||
let succeeded = false;
|
||||
await app.vault.process(file, (data) => {
|
||||
const lines = data.split("\n");
|
||||
if (bodyStart > bodyEnd || bodyEnd > lines.length) {
|
||||
aborted = true;
|
||||
return data;
|
||||
}
|
||||
const oldBody = lines.slice(bodyStart, bodyEnd);
|
||||
const newBody = applyUpdates(oldBody, updates);
|
||||
const newLines = [
|
||||
...lines.slice(0, bodyStart),
|
||||
...newBody,
|
||||
...lines.slice(bodyEnd),
|
||||
];
|
||||
succeeded = true;
|
||||
return newLines.join("\n");
|
||||
});
|
||||
|
||||
if (aborted) {
|
||||
new Notice("Chess block boundaries look wrong; aborting rewrite.");
|
||||
return false;
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `updates` to the body of a chess block. Non-rewritable lines pass
|
||||
* through unchanged; rewritable lines are stripped first, then the new values
|
||||
* are appended (preserving relative order: opening before variation).
|
||||
*/
|
||||
function applyUpdates(
|
||||
body: string[],
|
||||
updates: BlockRewriteUpdates
|
||||
): string[] {
|
||||
const preserved: string[] = [];
|
||||
for (const line of body) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) {
|
||||
preserved.push(line);
|
||||
continue;
|
||||
}
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon === -1) {
|
||||
preserved.push(line);
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.slice(0, colon).trim().toLowerCase();
|
||||
if (!REWRITABLE_KEYS.has(key)) {
|
||||
preserved.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop trailing blank lines so we don't accumulate extra whitespace
|
||||
// every time the user changes selection.
|
||||
while (preserved.length > 0 && preserved[preserved.length - 1]?.trim() === "") {
|
||||
preserved.pop();
|
||||
}
|
||||
|
||||
const newKeyLines: string[] = [];
|
||||
if (updates.opening && updates.opening.trim()) {
|
||||
newKeyLines.push(`opening: ${updates.opening.trim()}`);
|
||||
}
|
||||
if (updates.variation && updates.variation.trim()) {
|
||||
newKeyLines.push(`variation: ${updates.variation.trim()}`);
|
||||
}
|
||||
if (updates.endgame && updates.endgame.trim()) {
|
||||
newKeyLines.push(`endgame: ${updates.endgame.trim()}`);
|
||||
}
|
||||
if (updates.wccgame && updates.wccgame.trim()) {
|
||||
newKeyLines.push(`wccgame: ${updates.wccgame.trim()}`);
|
||||
}
|
||||
|
||||
if (preserved.length === 0) {
|
||||
return newKeyLines;
|
||||
}
|
||||
return [...preserved, ...newKeyLines];
|
||||
}
|
||||
94
src/utils/captured-pieces.ts
Normal file
94
src/utils/captured-pieces.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { PieceType } from "../types";
|
||||
import type { PositionStep } from "../chess/engine";
|
||||
|
||||
/**
|
||||
* Standard piece point values used to compute the material balance bar.
|
||||
* The king is intentionally excluded — kings are never captured and including
|
||||
* them would skew every position to a 0 balance.
|
||||
*/
|
||||
const PIECE_VALUE: Record<PieceType, number> = {
|
||||
p: 1,
|
||||
n: 3,
|
||||
b: 3,
|
||||
r: 5,
|
||||
q: 9,
|
||||
k: 0,
|
||||
};
|
||||
|
||||
/** Display order from lowest to highest value, matching Lichess. */
|
||||
export const TRAY_PIECE_ORDER: ReadonlyArray<PieceType> = [
|
||||
"p",
|
||||
"n",
|
||||
"b",
|
||||
"r",
|
||||
"q",
|
||||
];
|
||||
|
||||
export interface CapturedTotals {
|
||||
/** Pieces captured by White (i.e. former Black pieces). */
|
||||
byWhite: Record<PieceType, number>;
|
||||
/** Pieces captured by Black (i.e. former White pieces). */
|
||||
byBlack: Record<PieceType, number>;
|
||||
/**
|
||||
* Material advantage in pawn-equivalents from White's perspective.
|
||||
* Positive = White is up material, negative = Black is up.
|
||||
*
|
||||
* This is computed from captures + promotions so it stays accurate
|
||||
* even when the game starts from a non-standard FEN (we just measure
|
||||
* delta from the starting position rather than from a 39-point assumption).
|
||||
*/
|
||||
advantage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the steps array up to and including `untilIndex` to tally captured
|
||||
* pieces and material advantage.
|
||||
*
|
||||
* We use move history rather than naive FEN piece-counting because the latter
|
||||
* can't tell a captured queen apart from a promoted-and-then-traded queen.
|
||||
* Promotion bumps the promoting side's material by (promoted_value - 1) since
|
||||
* a pawn becomes the new piece.
|
||||
*/
|
||||
export function computeCaptured(
|
||||
steps: PositionStep[],
|
||||
untilIndex: number
|
||||
): CapturedTotals {
|
||||
const byWhite: Record<PieceType, number> = { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 };
|
||||
const byBlack: Record<PieceType, number> = { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 };
|
||||
let advantage = 0;
|
||||
|
||||
const stop = Math.min(untilIndex, steps.length - 1);
|
||||
// steps[0] is the starting position with no move; start at 1.
|
||||
for (let i = 1; i <= stop; i++) {
|
||||
const step = steps[i];
|
||||
if (!step) continue;
|
||||
|
||||
if (step.captured) {
|
||||
const value = PIECE_VALUE[step.captured] ?? 0;
|
||||
if (step.color === "w") {
|
||||
byWhite[step.captured]++;
|
||||
advantage += value;
|
||||
} else if (step.color === "b") {
|
||||
byBlack[step.captured]++;
|
||||
advantage -= value;
|
||||
}
|
||||
}
|
||||
|
||||
if (step.promotion) {
|
||||
const gain = (PIECE_VALUE[step.promotion] ?? 0) - PIECE_VALUE.p;
|
||||
if (step.color === "w") advantage += gain;
|
||||
else if (step.color === "b") advantage -= gain;
|
||||
}
|
||||
}
|
||||
|
||||
return { byWhite, byBlack, advantage };
|
||||
}
|
||||
|
||||
export function hasAnyCaptures(totals: CapturedTotals): boolean {
|
||||
if (totals.advantage !== 0) return true;
|
||||
for (const t of TRAY_PIECE_ORDER) {
|
||||
if (totals.byWhite[t] > 0) return true;
|
||||
if (totals.byBlack[t] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
153
src/utils/export-board.ts
Normal file
153
src/utils/export-board.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Export a rendered board SVG as raster (PNG) or vector (SVG) image, either
|
||||
* to the system clipboard or as a downloaded file.
|
||||
*
|
||||
* The board renderer outputs a self-contained SVG (inline gradients, no
|
||||
* external <image>/<use> refs), which is what makes the canvas-rasterize
|
||||
* round-trip safe — no taint, no CORS, no missing assets.
|
||||
*/
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export interface ExportOptions {
|
||||
/** Pixel multiplier for raster output. 2 = retina-quality 720x720 PNG. */
|
||||
scale?: number;
|
||||
/** Background fill color for raster output. PNG would be transparent without it. */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export async function copyBoardAsPng(
|
||||
svgEl: SVGElement,
|
||||
opts: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const blob = await rasterizeSvg(svgEl, opts);
|
||||
await writeBlobToClipboard(blob, "image/png");
|
||||
}
|
||||
|
||||
export async function copyBoardAsSvg(svgEl: SVGElement): Promise<void> {
|
||||
const xml = serializeSvg(svgEl);
|
||||
await navigator.clipboard.writeText(xml);
|
||||
}
|
||||
|
||||
export async function downloadBoardAsPng(
|
||||
svgEl: SVGElement,
|
||||
filename: string,
|
||||
opts: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const blob = await rasterizeSvg(svgEl, opts);
|
||||
triggerDownload(blob, filename);
|
||||
}
|
||||
|
||||
export async function downloadBoardAsSvg(
|
||||
svgEl: SVGElement,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
const xml = serializeSvg(svgEl);
|
||||
const blob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" });
|
||||
triggerDownload(blob, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the board's <svg> to a PNG blob. We clone the source so any
|
||||
* temporary attribute tweaks (xmlns, explicit width/height) don't affect
|
||||
* the live, on-screen board.
|
||||
*/
|
||||
async function rasterizeSvg(
|
||||
svgEl: SVGElement,
|
||||
opts: ExportOptions
|
||||
): Promise<Blob> {
|
||||
const scale = opts.scale ?? 2;
|
||||
const background = opts.background ?? "#ffffff";
|
||||
|
||||
const { width, height } = getViewBoxSize(svgEl);
|
||||
const xml = serializeSvg(svgEl);
|
||||
const dataUrl =
|
||||
"data:image/svg+xml;charset=utf-8," + encodeURIComponent(xml);
|
||||
|
||||
const img = await loadImage(dataUrl);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not acquire 2d canvas context");
|
||||
|
||||
if (background) {
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Canvas returned no blob"));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the SVG with explicit width/height attributes derived from its
|
||||
* viewBox so external tools (and the canvas raster path) know how big to
|
||||
* draw it. Without these, browsers default to "intrinsic 0x0" for inline-
|
||||
* rendered SVGs.
|
||||
*/
|
||||
function serializeSvg(svgEl: SVGElement): string {
|
||||
const clone = svgEl.cloneNode(true) as SVGElement;
|
||||
if (!clone.getAttribute("xmlns")) {
|
||||
clone.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
const { width, height } = getViewBoxSize(svgEl);
|
||||
clone.setAttribute("width", String(width));
|
||||
clone.setAttribute("height", String(height));
|
||||
return new XMLSerializer().serializeToString(clone);
|
||||
}
|
||||
|
||||
function getViewBoxSize(svgEl: SVGElement): { width: number; height: number } {
|
||||
const vb = svgEl.getAttribute("viewBox");
|
||||
if (vb) {
|
||||
const parts = vb.split(/\s+/).map(Number);
|
||||
if (parts.length === 4 && !parts.some(isNaN)) {
|
||||
return { width: parts[2] ?? 360, height: parts[3] ?? 360 };
|
||||
}
|
||||
}
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
return { width: rect.width || 360, height: rect.height || 360 };
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Could not load SVG into image"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function writeBlobToClipboard(
|
||||
blob: Blob,
|
||||
mimeType: string
|
||||
): Promise<void> {
|
||||
if (typeof ClipboardItem === "undefined") {
|
||||
throw new Error(
|
||||
"Clipboard image API not available in this environment"
|
||||
);
|
||||
}
|
||||
const item = new ClipboardItem({ [mimeType]: blob });
|
||||
await navigator.clipboard.write([item]);
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Defer revoke so the browser has time to start the download.
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
299
src/utils/parser.ts
Normal file
299
src/utils/parser.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import {
|
||||
ALL_PIECE_SETS,
|
||||
type BoardSize,
|
||||
type ChessBlockConfig,
|
||||
type ExplorerSource,
|
||||
type Orientation,
|
||||
type PieceSet,
|
||||
} from "../types";
|
||||
|
||||
const VALID_SIZES = new Set<BoardSize>(["small", "medium", "large", "full"]);
|
||||
const VALID_PIECE_SETS = new Set<PieceSet>(ALL_PIECE_SETS);
|
||||
|
||||
/** All recognized config keys (lowercase). Used by the raw-PGN heuristic. */
|
||||
const CONFIG_KEYS = new Set([
|
||||
"opening",
|
||||
"variation",
|
||||
"endgame",
|
||||
"wccgame",
|
||||
"wcc",
|
||||
"moves",
|
||||
"pgn",
|
||||
"lichess",
|
||||
"lichessgame",
|
||||
"fen",
|
||||
"orientation",
|
||||
"side",
|
||||
"pieces",
|
||||
"pieceset",
|
||||
"light",
|
||||
"lightcolor",
|
||||
"lightsquare",
|
||||
"dark",
|
||||
"darkcolor",
|
||||
"darksquare",
|
||||
"title",
|
||||
"name",
|
||||
"size",
|
||||
"boardsize",
|
||||
"showmoves",
|
||||
"movelist",
|
||||
"moveslist",
|
||||
"movespanel",
|
||||
"interactive",
|
||||
"controls",
|
||||
"coordinates",
|
||||
"coords",
|
||||
"explorer",
|
||||
"explorersource",
|
||||
"explorerdb",
|
||||
"source",
|
||||
"startmove",
|
||||
"step",
|
||||
"atmove",
|
||||
"arrows",
|
||||
"arrow",
|
||||
"highlights",
|
||||
"highlight",
|
||||
"squares",
|
||||
"captured",
|
||||
"capturedpieces",
|
||||
"materialbar",
|
||||
"analyze",
|
||||
"engine",
|
||||
"stockfish",
|
||||
"play",
|
||||
"vs",
|
||||
"vsstockfish",
|
||||
"level",
|
||||
"skill",
|
||||
"skilllevel",
|
||||
"strength",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse the body of a ```chess code block.
|
||||
*
|
||||
* Two styles are accepted:
|
||||
*
|
||||
* 1. Structured — lines of `key: value` (one per line). Lines starting with
|
||||
* `#` or `//` are comments. Multi-word values are taken verbatim. Unknown
|
||||
* keys are silently ignored. Keys are case-insensitive.
|
||||
*
|
||||
* 2. Raw PGN — if the body looks like a PGN (starts with `[Tag "value"]`
|
||||
* headers, or contains a SAN move sequence with no `key: value` lines),
|
||||
* it's treated as if the entire body were a `pgn:` value. This lets you
|
||||
* paste a PGN copied from Lichess/chess.com/ChessBase directly.
|
||||
*/
|
||||
export function parseBlockConfig(source: string): ChessBlockConfig {
|
||||
const cfg: ChessBlockConfig = {};
|
||||
|
||||
if (looksLikeRawPgn(source)) {
|
||||
cfg.pgn = source.trim();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const lines = source.split(/\r?\n/);
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#") || line.startsWith("//")) continue;
|
||||
|
||||
const colon = line.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const key = line.slice(0, colon).trim().toLowerCase();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
if (!value) continue;
|
||||
|
||||
assignKey(cfg, key, value);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: a body is "raw PGN" if its first significant line is a PGN
|
||||
* tag pair (`[Event "..."]`), or if no line in the body matches our
|
||||
* `key: value` shape but at least one move-like token is present.
|
||||
*/
|
||||
function looksLikeRawPgn(source: string): boolean {
|
||||
const lines = source
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("#") && !l.startsWith("//"));
|
||||
|
||||
if (lines.length === 0) return false;
|
||||
|
||||
// Strong signal: first line is a PGN tag pair.
|
||||
if (/^\[[A-Za-z][A-Za-z0-9]*\s+"[^"]*"\s*\]\s*$/.test(lines[0] ?? "")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Weak signal: no `key: value` lines but body looks like SAN moves.
|
||||
const hasKeyValue = lines.some((l) => {
|
||||
const colon = l.indexOf(":");
|
||||
if (colon <= 0) return false;
|
||||
const key = l.slice(0, colon).trim().toLowerCase();
|
||||
// Recognized config keys count as "looks like structured config".
|
||||
return CONFIG_KEYS.has(key);
|
||||
});
|
||||
if (hasKeyValue) return false;
|
||||
|
||||
const moveLikeToken = /\b(?:O-O(?:-O)?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=[QRBN])?[+#]?|[a-h][1-8])/;
|
||||
return lines.some((l) => moveLikeToken.test(l));
|
||||
}
|
||||
|
||||
function assignKey(cfg: ChessBlockConfig, key: string, value: string): void {
|
||||
switch (key) {
|
||||
case "opening":
|
||||
cfg.opening = value;
|
||||
return;
|
||||
case "variation":
|
||||
cfg.variation = value;
|
||||
return;
|
||||
case "endgame":
|
||||
cfg.endgame = value;
|
||||
return;
|
||||
case "wccgame":
|
||||
case "wcc":
|
||||
cfg.wccgame = value;
|
||||
return;
|
||||
case "moves":
|
||||
cfg.moves = value;
|
||||
return;
|
||||
case "pgn":
|
||||
cfg.pgn = value;
|
||||
return;
|
||||
case "lichess":
|
||||
case "lichessgame":
|
||||
cfg.lichess = value;
|
||||
return;
|
||||
case "fen":
|
||||
cfg.fen = value;
|
||||
return;
|
||||
case "orientation":
|
||||
case "side": {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === "white" || lower === "black") {
|
||||
cfg.orientation = lower as Orientation;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "pieces":
|
||||
case "pieceset": {
|
||||
const lower = value.toLowerCase() as PieceSet;
|
||||
if (VALID_PIECE_SETS.has(lower)) {
|
||||
cfg.pieces = lower;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "light":
|
||||
case "lightcolor":
|
||||
case "lightsquare":
|
||||
cfg.light = value;
|
||||
return;
|
||||
case "dark":
|
||||
case "darkcolor":
|
||||
case "darksquare":
|
||||
cfg.dark = value;
|
||||
return;
|
||||
case "title":
|
||||
case "name":
|
||||
cfg.title = value;
|
||||
return;
|
||||
case "size":
|
||||
case "boardsize": {
|
||||
const lower = value.toLowerCase() as BoardSize;
|
||||
if (VALID_SIZES.has(lower)) cfg.size = lower;
|
||||
return;
|
||||
}
|
||||
case "showmoves":
|
||||
case "movelist":
|
||||
case "moveslist":
|
||||
case "movespanel": {
|
||||
const bool = parseBool(value);
|
||||
if (bool !== null) cfg.showMoves = bool;
|
||||
return;
|
||||
}
|
||||
case "interactive":
|
||||
case "controls": {
|
||||
const bool = parseBool(value);
|
||||
if (bool !== null) cfg.interactive = bool;
|
||||
return;
|
||||
}
|
||||
case "coordinates":
|
||||
case "coords": {
|
||||
const bool = parseBool(value);
|
||||
if (bool !== null) cfg.coordinates = bool;
|
||||
return;
|
||||
}
|
||||
case "explorer": {
|
||||
const bool = parseBool(value);
|
||||
if (bool !== null) cfg.explorer = bool;
|
||||
return;
|
||||
}
|
||||
case "startmove":
|
||||
case "step":
|
||||
case "atmove": {
|
||||
const n = parseInt(value, 10);
|
||||
if (!isNaN(n)) cfg.startMove = n;
|
||||
return;
|
||||
}
|
||||
case "explorersource":
|
||||
case "explorerdb":
|
||||
case "source": {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === "masters" || lower === "lichess") {
|
||||
cfg.explorerSource = lower as ExplorerSource;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "arrows":
|
||||
case "arrow":
|
||||
cfg.arrows = value;
|
||||
return;
|
||||
case "highlights":
|
||||
case "highlight":
|
||||
case "squares":
|
||||
cfg.highlights = value;
|
||||
return;
|
||||
case "captured":
|
||||
case "capturedpieces":
|
||||
case "materialbar": {
|
||||
const bool = parseBool(value);
|
||||
if (bool !== null) cfg.captured = bool;
|
||||
return;
|
||||
}
|
||||
case "analyze":
|
||||
case "engine":
|
||||
case "stockfish": {
|
||||
const bool = parseBool(value);
|
||||
if (bool !== null) cfg.analyze = bool;
|
||||
return;
|
||||
}
|
||||
case "play":
|
||||
case "vs":
|
||||
case "vsstockfish": {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === "white" || lower === "black" || lower === "random") {
|
||||
cfg.play = lower;
|
||||
} else if (parseBool(lower) === true) {
|
||||
cfg.play = "white";
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "level":
|
||||
case "skill":
|
||||
case "skilllevel":
|
||||
case "strength": {
|
||||
const n = parseInt(value, 10);
|
||||
if (!isNaN(n)) cfg.level = Math.max(0, Math.min(n, 20));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseBool(value: string): boolean | null {
|
||||
const v = value.toLowerCase();
|
||||
if (["true", "yes", "on", "1"].includes(v)) return true;
|
||||
if (["false", "no", "off", "0"].includes(v)) return false;
|
||||
return null;
|
||||
}
|
||||
10
src/utils/user-agent.ts
Normal file
10
src/utils/user-agent.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import manifest from "../../manifest.json";
|
||||
|
||||
/**
|
||||
* Identifying User-Agent for all outgoing HTTPS requests this plugin makes.
|
||||
* Lichess (and most public APIs) ask third-party clients to set a descriptive
|
||||
* UA so they can identify and contact the developer if traffic patterns
|
||||
* become problematic. The version is pulled from manifest.json so it stays
|
||||
* in sync with releases automatically.
|
||||
*/
|
||||
export const USER_AGENT = `${manifest.id}/${manifest.version} (https://github.com/${manifest.author ?? "ekrizdis"}/${manifest.id})`;
|
||||
48
src/vendor/stockfish-engine.sftext
vendored
Normal file
48
src/vendor/stockfish-engine.sftext
vendored
Normal file
File diff suppressed because one or more lines are too long
905
styles.css
Normal file
905
styles.css
Normal file
|
|
@ -0,0 +1,905 @@
|
|||
/* Caissa plugin styles */
|
||||
|
||||
.chess-study-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
color: var(--text-normal);
|
||||
/* Lets us use container queries against the writing pane width
|
||||
rather than the full viewport — important for split-pane editing. */
|
||||
container-type: inline-size;
|
||||
container-name: chess-block;
|
||||
}
|
||||
|
||||
.chess-study-title {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.chess-study-description {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Game header (PGN headers strip) ----------------------------------- */
|
||||
|
||||
.chess-study-game-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.chess-study-players {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chess-study-player {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.chess-study-player-dot {
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
transform: translateY(0.05rem);
|
||||
}
|
||||
|
||||
.chess-study-player-white .chess-study-player-dot {
|
||||
background: #f4f4f4;
|
||||
}
|
||||
.chess-study-player-black .chess-study-player-dot {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.chess-study-player-title {
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
color: var(--text-accent);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chess-study-player-elo {
|
||||
font-weight: 400;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chess-study-versus {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.chess-study-result {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.chess-study-game-meta {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Inline category/opening/endgame/WCC picker ------------------------ */
|
||||
|
||||
.chess-study-inline-picker-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.chess-study-inline-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chess-study-inline-picker-subrows,
|
||||
.chess-study-inline-picker-gamerow-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chess-study-inline-picker-subrows:empty,
|
||||
.chess-study-inline-picker-gamerow-wrap:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chess-study-inline-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chess-study-inline-picker-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.chess-study-inline-picker-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* --- Loading indicator (async game fetch) ------------------------------ */
|
||||
|
||||
.chess-study-loading {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* --- Layout ------------------------------------------------------------ */
|
||||
|
||||
.chess-study-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Soft focus ring so users know keyboard shortcuts (←/→, Home/End, F)
|
||||
are active. Only visible when focused via keyboard or after clicking. */
|
||||
.chess-study-layout:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Default (`size: medium`): board capped at 360px, moves to the right. */
|
||||
.chess-study-layout.size-medium {
|
||||
grid-template-columns: minmax(220px, 360px) minmax(180px, 1fr);
|
||||
}
|
||||
.chess-study-layout.size-medium .chess-study-board-wrap,
|
||||
.chess-study-layout.size-medium .chess-study-controls {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.chess-study-layout.size-small {
|
||||
grid-template-columns: minmax(180px, 240px) minmax(160px, 1fr);
|
||||
}
|
||||
.chess-study-layout.size-small .chess-study-board-wrap,
|
||||
.chess-study-layout.size-small .chess-study-controls {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.chess-study-layout.size-large {
|
||||
grid-template-columns: minmax(280px, 500px) minmax(200px, 1fr);
|
||||
}
|
||||
.chess-study-layout.size-large .chess-study-board-wrap,
|
||||
.chess-study-layout.size-large .chess-study-controls {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* `size: full` always stacks (board full-width, moves below). */
|
||||
.chess-study-layout.size-full {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.chess-study-layout.size-full .chess-study-board-wrap,
|
||||
.chess-study-layout.size-full .chess-study-controls {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* No moves panel — board takes the full row regardless of size, but caps
|
||||
stay in place so a non-`full` board doesn't suddenly stretch. With nothing
|
||||
to the right of the board, center the board (and its controls) within the
|
||||
available width instead of letting it left-align awkwardly. */
|
||||
.chess-study-layout.no-moves {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.chess-study-layout.no-moves .chess-study-board-col {
|
||||
align-items: center;
|
||||
}
|
||||
.chess-study-layout.no-moves .chess-study-board-wrap,
|
||||
.chess-study-layout.no-moves .chess-study-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Container-query collapse: when the writing area is narrow, every size
|
||||
except the already-stacked `full`/`no-moves` collapses to a single column
|
||||
(board on top, moves below) — this is the phone/narrow-pane behavior. */
|
||||
@container chess-block (max-width: 520px) {
|
||||
.chess-study-layout.size-small,
|
||||
.chess-study-layout.size-medium,
|
||||
.chess-study-layout.size-large {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.chess-study-layout.size-small .chess-study-board-wrap,
|
||||
.chess-study-layout.size-small .chess-study-controls,
|
||||
.chess-study-layout.size-medium .chess-study-board-wrap,
|
||||
.chess-study-layout.size-medium .chess-study-controls,
|
||||
.chess-study-layout.size-large .chess-study-board-wrap,
|
||||
.chess-study-layout.size-large .chess-study-controls {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Board column ------------------------------------------------------ */
|
||||
|
||||
.chess-study-board-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chess-study-board-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.chess-study-board {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* --- Captured pieces trays + material balance -------------------------- */
|
||||
|
||||
.chess-study-captured-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
min-height: 1.4rem;
|
||||
padding: 0.1rem 0.15rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.chess-study-captured-wrap.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chess-study-captured-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Stack same-piece copies tightly so a 7-pawn capture doesn't take half
|
||||
the board's width. The negative margin overlaps neighbors slightly. */
|
||||
.chess-study-captured-group .chess-study-captured-piece + .chess-study-captured-piece {
|
||||
margin-left: -0.55em;
|
||||
}
|
||||
|
||||
.chess-study-captured-piece {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chess-study-captured-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chess-study-captured-glyph {
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chess-study-captured-adv {
|
||||
margin-left: 0.5em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Controls ---------------------------------------------------------- */
|
||||
|
||||
.chess-study-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.chess-study-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chess-study-control:hover:not([disabled]) {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.chess-study-control[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chess-study-control-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* --- Move list --------------------------------------------------------- */
|
||||
|
||||
.chess-study-side-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chess-study-moves-wrap {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chess-study-moves {
|
||||
display: block;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chess-study-move-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr 1fr;
|
||||
gap: 0.25rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.chess-study-moves-empty {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.chess-study-move-row.header {
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
margin-bottom: 0.25rem;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.chess-study-move-row.header .chess-study-move-cell {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chess-study-move-num {
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
padding-right: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chess-study-move-cell {
|
||||
cursor: pointer;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.1s ease;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
.chess-study-move-cell:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.chess-study-move-cell.active {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chess-study-move-cell.empty {
|
||||
cursor: default;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- Errors ------------------------------------------------------------ */
|
||||
|
||||
.chess-study-error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--background-modifier-error);
|
||||
color: var(--text-error);
|
||||
font-family: var(--font-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* --- Lines (opening explorer) panel ----------------------------------- */
|
||||
|
||||
.chess-study-lines-wrap {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.chess-study-lines-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chess-study-lines-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chess-study-lines-meta {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chess-study-lines-status {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.chess-study-lines-status.error {
|
||||
color: var(--text-error);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.chess-study-lines-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.chess-study-lines-row {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chess-study-lines-label {
|
||||
font-family: var(--font-monospace, monospace);
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chess-study-lines-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 1.6rem;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
.chess-study-lines-seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
.chess-study-lines-seg.seg-white {
|
||||
background: #2e8b3a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chess-study-lines-seg.seg-draws {
|
||||
background: #a8a8a8;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.chess-study-lines-seg.seg-black {
|
||||
background: #d6383b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chess-study-lines-seg-label {
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* --- Openings quiz modal ---------------------------------------------- */
|
||||
|
||||
.chess-study-quiz-modal {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.chess-study-quiz-help {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.chess-study-quiz-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.chess-study-quiz-streak {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.chess-study-quiz-board {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto 0.75rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.chess-study-quiz-board svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chess-study-quiz-prompt {
|
||||
font-size: 0.95em;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chess-study-quiz-side {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chess-study-quiz-mystery {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chess-study-quiz-input-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.chess-study-quiz-input-row .is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chess-study-quiz-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
font-size: 0.95em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.chess-study-quiz-input:focus {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.chess-study-quiz-feedback {
|
||||
min-height: 1.4em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chess-study-quiz-feedback.is-correct {
|
||||
color: var(--text-success, #2e8b3a);
|
||||
}
|
||||
|
||||
.chess-study-quiz-feedback.is-incorrect {
|
||||
color: var(--text-error, #d6383b);
|
||||
}
|
||||
|
||||
.chess-study-quiz-reveal {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* --- Engine analysis panel + eval graph -------------------------------- */
|
||||
|
||||
.chess-study-analysis-wrap {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chess-study-analysis {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.chess-study-eval-bar {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
min-height: 80px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.chess-study-eval-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.chess-study-eval-fill.is-white {
|
||||
bottom: 0;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.chess-study-eval-fill.is-black {
|
||||
top: 0;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.chess-study-eval-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 50%;
|
||||
transform: translateY(50%);
|
||||
text-align: center;
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
color: var(--text-on-accent, #fff);
|
||||
mix-blend-mode: difference;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chess-study-engine-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.chess-study-engine-loading,
|
||||
.chess-study-engine-empty,
|
||||
.chess-study-engine-error {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chess-study-engine-error {
|
||||
color: var(--text-error, #d6383b);
|
||||
}
|
||||
|
||||
.chess-study-engine-line {
|
||||
display: grid;
|
||||
grid-template-columns: 4ch 3ch 1fr;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chess-study-engine-eval {
|
||||
font-weight: 600;
|
||||
color: var(--text-accent);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chess-study-engine-depth {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.chess-study-engine-pv {
|
||||
color: var(--text-normal);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Eval graph */
|
||||
|
||||
.chess-study-eval-graph-wrap {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chess-study-eval-graph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.85em;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-btn:hover:not([disabled]) {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-btn[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-status {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.chess-study-eval-graph-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* --- Play vs Stockfish ------------------------------------------------- */
|
||||
|
||||
.chess-study-play-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.5em;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.chess-study-play-status-text {
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.chess-study-play-status-text.is-thinking {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chess-study-play-status-text.is-gameover {
|
||||
color: var(--text-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chess-study-play-controls {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Make the board cursor signal that it's clickable in play mode. */
|
||||
.chess-study-board.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chess-study-board-target {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Promotion picker modal ------------------------------------------- */
|
||||
|
||||
.chess-study-promotion-modal .modal-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chess-study-promotion-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.chess-study-promotion-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chess-study-promotion-choice:hover {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.chess-study-promotion-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strictBindCallApply": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
4
versions.json
Normal file
4
versions.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"0.1.0": "1.4.0",
|
||||
"1.0.0": "1.12.0"
|
||||
}
|
||||
Loading…
Reference in a new issue