1 Commits

Author SHA1 Message Date
CI 56c0514d02 deploy 2025-12-21 00:16:11 +00:00
32 changed files with 33 additions and 4275 deletions
-16
View File
@@ -1,16 +0,0 @@
name: Build and deploy
on:
release:
types: [published]
jobs:
build_vue:
runs-on: ubuntu-latest
name: Build Vue
steps:
- uses: actions/checkout@v2
- id: Build-Vue
uses: xRealNeon/VuePagesAction@1.0.1
with:
username: 'YouHaveTrouble'
reponame: 'DiscipleOfLand'
token: ${{ secrets.GITHUB_TOKEN }}
-13
View File
@@ -1,13 +0,0 @@
name: Test
on: [push]
jobs:
build_vue:
runs-on: ubuntu-latest
name: Build and lint
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install
run: npm install
- name: Build and lint
run: npm run build
-28
View File
@@ -1,28 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Symlink
+1
View File
@@ -0,0 +1 @@
index.html
-141
View File
@@ -1,141 +0,0 @@
# How to contribute
All meaningful contributions are welcome. If you're not sure about something,
open an issue or [join the discord server](https://discord.youhavetrouble.me/)
to talk it out.
## Reporting issues
If you find a bug or unexpected behavior, open an issue. Include information about
how to reproduce the issue, and any relevant error messages from your browser's
console.
## Pull requests
PLEASE OPEN AN ISSUE OR DISCUSS ON DISCORD BEFORE MAKING A PULL REQUEST.
Noone likes to waste time on a PR that won't be accepted, so please ask first!
## Inputting data
Entire project is data-driven and contributions can be made either via pull request or
submitting a json file with the data formatted as described below. If anything's unclear,
[ask on discord](https://discord.youhavetrouble.me/).
### Items
You can see the existing data [here](https://github.com/YouHaveTrouble/DiscipleOfLand/blob/master/public/data/items.json).
Item data adheres to following json schema:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9-]+$": {
"type": "object",
"properties": {
"name": { "type": "string" },
"level": { "type": "integer" },
"stars": { "type": "integer" },
"perception": { "type": "integer" }
},
"required": ["name", "level"]
}
},
"additionalProperties": false
}
```
### Zones
You can see the existing data [here](https://github.com/YouHaveTrouble/DiscipleOfLand/blob/master/public/data/zones.json).
Zone data adheres to following json schema:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9-]+$": {
"type": "object",
"properties": {
"name": {
"type": "object",
"properties": {
"en": { "type": "string" }
},
"required": ["en"]
},
"aetherytes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" }
},
"required": ["x", "y"]
},
"name": {
"type": "object",
"properties": {
"en": { "type": "string" }
},
"required": ["en"]
}
},
"required": ["position", "name"]
}
}
},
"required": ["name", "aetherytes"]
}
},
"additionalProperties": false
}
```
### Nodes
You can see the existing data [here](https://github.com/YouHaveTrouble/DiscipleOfLand/blob/master/public/data/nodes.json).
Node data adheres to following json schema:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"job": {
"type": "string",
"enum": ["botanist", "miner"]
},
"type": {
"type": "string",
"enum": ["unspoiled", "legendary"]
},
"position": {
"type": "object",
"properties": {
"zone": { "type": "string" },
"x": { "type": "number" },
"y": { "type": "number" }
},
"required": ["zone", "x", "y"]
},
"times": {
"type": "array",
"items": { "type": "string", "pattern": "^\\d{2}:\\d{2}-\\d{2}:\\d{2}$" },
"minItems": 2,
"maxItems": 2
},
"items": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["job", "type", "position", "times", "items"]
}
```
`times` is an array of two strings representing the time the node is available in Eorzea time.
First value is the time the node becomes available, second value is the time the node disappears.
`items` elements are ids corresponding to the items in the items.json file.
-24
View File
@@ -1,24 +0,0 @@
# Disciple of Land
I deemed existing gathering timers to be inadequate, so I made my own.
This is currently still missing a lot of functionality and data.
## 1.0 checklist
- [x] Displaying gathering nodes
- [x] Displaying next time gathering node will be available
- [x] Displaying gathering node type
- [x] Displaying node's items and their level
- [x] Sorting based on time when node will be available
- [x] Filter and sorting section/popup
- [x] Filtering based on job
- [x] Filtering based on level
- [x] Filtering based on node type (legendary, ephemeral, etc.)
- [ ] Fully input level 80-100 nodes data
## Nice to have checklist
- [ ] Fully input level 70-80 nodes data
- [ ] Fully input level 60-70 nodes data
- [ ] Fully input level 50-60 nodes data
- [ ] Better mobile support (pwa?)
+1
View File
@@ -0,0 +1 @@
@charset "UTF-8";*{box-sizing:border-box;font-family:Roboto,sans-serif;font-size:16px;font-weight:400;color:#eaeaea}body{background-color:#2a2a2a;margin:0;padding:0}#app{max-width:1280px;margin:0 auto;display:flex;flex-direction:column;gap:.5rem;font-weight:400}@keyframes pulsing-a636ed1c{0%{background-color:#ffffff0d}50%{background-color:#ffffff13}to{background-color:#ffffff0d}}.node[data-v-a636ed1c]{display:flex;flex-direction:row;flex-wrap:wrap;gap:1rem;width:100%;min-height:6rem;border:1px solid #fff;padding:.5rem;border-radius:.25rem;content-visibility:auto}.node .location-info[data-v-a636ed1c]{display:flex;flex-direction:row;flex-wrap:wrap;gap:1rem;align-items:center}.node.active[data-v-a636ed1c]{animation:infinite pulsing-a636ed1c 6s}.node .timer[data-v-a636ed1c]{min-width:7rem;font-size:2rem;display:flex;height:100%;flex-direction:column;justify-content:center;align-items:center}.node .job[data-v-a636ed1c]{display:flex;justify-content:center;align-items:center}.node .job .icon[data-v-a636ed1c]{width:3rem;height:3rem}.node .job .icon img[data-v-a636ed1c]{width:100%;height:100%}.node .aetheryte[data-v-a636ed1c]{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:.25rem;font-size:1.5rem;border-radius:.75rem;padding:.35rem 1rem;background-color:#0003}.node .aetheryte .icon[data-v-a636ed1c]{width:3rem}.node .aetheryte .icon img[data-v-a636ed1c]{width:100%;height:100%;object-fit:fill}.node .aetheryte .info[data-v-a636ed1c]{display:flex;flex-direction:column;justify-content:center;align-items:start;gap:.1rem;line-height:1}.node .aetheryte .info .zone-name[data-v-a636ed1c]{font-size:.75rem}.node .aetheryte .info .aetheryte-name[data-v-a636ed1c]{font-size:1.1rem}.node .aetheryte .info .coords[data-v-a636ed1c]{padding-top:.15rem}.node .items[data-v-a636ed1c]{display:flex;flex-direction:column;justify-content:center;align-items:start;gap:.1rem}.node-list[data-v-35ea1fd0]{display:flex;flex-direction:column;gap:.33rem;padding-block:.5rem;padding-inline:.25rem}section[data-v-fc9f5c52]{display:flex;flex-direction:column;gap:1rem;padding-block:1rem;padding-inline:.25rem}section details[data-v-fc9f5c52]{background-color:#1f1f1f;display:flex;flex-direction:column;border-radius:.25rem;padding:1rem;gap:.5rem;position:relative}section details[data-v-fc9f5c52]:before{position:absolute;right:1rem;top:1rem;width:1.5rem;height:1.5rem;display:flex;justify-content:center;align-content:center;content:"▶";pointer-events:none;rotate:90deg;transition:rotate .25s}section details[open][data-v-fc9f5c52]:before{rotate:270deg}section details summary[data-v-fc9f5c52]{cursor:pointer;color:#fff;border:none;border-radius:.5rem;display:flex;justify-content:space-between;align-items:center;font-size:1.25rem}section details section[data-v-fc9f5c52]{display:flex;flex-direction:row;gap:2rem;flex-wrap:wrap}section details section label[data-v-fc9f5c52]{display:flex;gap:.5rem;flex-direction:column}section details section label.horizontal[data-v-fc9f5c52]{flex-direction:row;gap:.2rem}section details section label input[type=number][data-v-fc9f5c52]{padding:.5rem;border:1px solid #fff;background-color:transparent;color:#fff;border-radius:.25rem;width:5rem;text-align:start}.github-logo[data-v-abcb3caa]{width:var(--80f95432);height:var(--80f95432)}nav[data-v-5cc282e7]{background-color:#1f1f1f;width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;padding-inline:1rem}nav .current-eorzea-time[data-v-5cc282e7]{font-size:3rem}nav .top-bar-menu[data-v-5cc282e7]{display:flex;gap:1rem;flex-direction:row}nav .filters-button[data-v-5cc282e7]{display:flex;padding:.5rem;border:1px solid #fff;background-color:transparent;cursor:pointer;width:max-content;white-space:nowrap}nav .filters-button.active[data-v-5cc282e7]{background-color:#ffffff1a}footer[data-v-5cc282e7]{display:flex;flex-direction:column;justify-content:center;align-items:center;padding:1rem .25rem;gap:.5rem}footer p[data-v-5cc282e7]{margin:0}
File diff suppressed because one or more lines are too long
@@ -134,5 +134,11 @@
"level": 90,
"stars": 2,
"perception": 3600
},
"shaaloani-coffee": {
"name": "Shaaloani Coffee",
"level": 100,
"stars": 2,
"perception": 4740
}
}
+17 -1
View File
@@ -249,7 +249,7 @@
]
},
{
"job": "botanist",
"job": "miner",
"type": "unspoiled",
"position": {
"zone": "kozamauka",
@@ -311,5 +311,21 @@
"items": [
"paldao-log"
]
},
{
"job": "botanist",
"type": "legendary",
"position": {
"zone": "shaaloani",
"x": 34.9,
"y": 16.3
},
"times": [
"04:00-06:00",
"16:00-18:00"
],
"items": [
"shaaloani-coffee"
]
}
]
+7 -1
View File
@@ -13,12 +13,18 @@
<meta property="twitter:description" content="Track timed gathering nodes in Final Fantasy XIV.">
<meta property="og-url" content="https://dol.yht.one">
<meta property="twitter:url" content="https://dol.yht.one">
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-8262480617556085"
crossorigin="anonymous"
></script>
<script type="module" crossorigin src="/assets/index-CO5bRgec.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bmabhb_D.css">
</head>
<body>
<noscript>
<strong>We're sorry but Disciple of Land doesn't work without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
-2849
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
{
"name": "discipleofland",
"version": "0.0.8",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && cp -r CNAME dist/CNAME",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4"
},
"devDependencies": {
"sass": "^1.67.0",
"typescript": "^5.2.2",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-typescript": "^12.0.0",
"eslint": "^8.51.0",
"eslint-plugin-vue": "^9.17.0",
"vite": "^5.3.3",
"vue-eslint-parser": "^9.3.2",
"vite-plugin-eslint": "^1.8.1"
},
"eslintConfig": {
"root": true,
"parser": "vue-eslint-parser",
"extends": [
"plugin:vue/strongly-recommended",
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"@vue/typescript/recommended"
],
"plugins": ["@typescript-eslint"],
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2020
},
"ignorePatterns": [
"dist/",
"node_modules/",
".github/"
]
}
}
-240
View File
@@ -1,240 +0,0 @@
<template>
<div>
<nav>
<div class="current-eorzea-time">
{{ eorzeaTime.getPrettyTime() }}
</div>
<div class="top-bar-menu">
<a
href="https://github.com/YouHaveTrouble/DiscipleOfLand"
target="_blank"
>
<GithubLogo
color="#fff"
size="2rem"
/>
</a>
<button
class="filters-button"
:class="{ active: filtersActive}"
@click="filtersActive = !filtersActive"
>
{{ filtersActive ? ' Close filters' : 'Open filters' }}
</button>
</div>
</nav>
<main>
<SortedNodeList
v-if="!filtersActive"
:nodes="nodes as Node[]"
:zones="zones"
:eorzea-time="eorzeaTime"
/>
<FiltersMenu
v-if="filtersActive"
/>
</main>
<footer>
<p>v{{ version }}</p>
<p><a href="https://github.com/YouHaveTrouble/DiscipleOfLand/blob/master/CONTRIBUTING.MD">Need YOUR help to input node, item and zone data!</a></p>
</footer>
</div>
</template>
<script lang="ts">
import {defineComponent} from "vue";
import EorzeaTime from "@/util/EorzeaTime";
import Node from "@/entities/Node";
import Aetheryte from "@/entities/Aetheryte";
import Item from "@/entities/Item";
import {jobFromString} from "@/enums/Job";
import {nodeTypeFromString} from "@/enums/NodeType";
import SortedNodeList from "@/components/SortedNodeList.vue";
import TimeRange from "@/entities/TimeRange";
import Zone from "@/entities/Zone";
import FiltersMenu from "@/components/FiltersMenu.vue";
import Filters from "@/util/Filters";
import GithubLogo from "@/components/icons/GithubLogo.vue";
import {version} from "../package.json";
export default defineComponent({
name: 'App',
components: {GithubLogo, FiltersMenu, SortedNodeList},
data: () => ({
version: version,
eorzeaTime: new EorzeaTime() as EorzeaTime,
nodes: [] as Node[],
items: {} as { [key: string]: Item },
zones: {} as { [key: string]: Zone },
filtersActive: false,
filters: new Filters(),
}),
methods: {
findNearestAetheryte(zoneName: string, x: number, y: number): Aetheryte | null {
let result = null;
let distance = Number.MAX_SAFE_INTEGER;
const zone = this.zones[zoneName]
if (!zone) return null;
for (const aetheryte of zone.aetherytes) {
const a = aetheryte.position.x - x;
const b = aetheryte.position.y - y;
const distanceToAetheryte = Math.hypot(a, b);
if (distanceToAetheryte < distance) {
distance = distanceToAetheryte;
result = aetheryte;
}
}
return result;
}
},
async mounted() {
this.eorzeaTime = new EorzeaTime();
setInterval(() => {
this.eorzeaTime = new EorzeaTime();
}, 500);
const itemData: Response | null = await fetch("/data/items.json")
.catch((): null => {
return null;
});
if (itemData === null) {
console.error("Failed to fetch item data!")
return;
}
const items = await itemData.json();
for (const itemId in items) {
const itemData = items[itemId];
this.items[itemId] = new Item(itemId, itemData);
}
const zoneData: Response | null = await fetch("/data/zones.json")
.catch((): null => {
return null;
});
if (zoneData === null) {
console.error("Failed to fetch zone data!")
return;
}
const zones = await zoneData.json();
for (const zoneId in zones) {
this.zones[zoneId] = new Zone(zones[zoneId]);
}
const nodeData = await fetch("/data/nodes.json")
.catch(() => {
return null;
});
if (nodeData === null) {
console.error("Failed to fetch node data!")
return;
}
const nodes = await nodeData.json();
for (const nodeData of nodes) {
const job = jobFromString(nodeData.job);
if (job === null) {
console.debug(`Failed to parse job: ${nodeData.job}`);
continue;
}
const nodeType = nodeTypeFromString(nodeData.type);
if (nodeType === null) {
console.debug(`Failed to parse node type: ${nodeData.type}`);
continue;
}
const items = [] as Item[];
for (const itemId of nodeData.items) {
const item = this.items[itemId];
if (item === undefined) {
console.debug(`Failed to find item with id: ${itemId}`);
continue;
}
items.push(item);
}
const times = [] as TimeRange[];
for (const timeRangeEntry of nodeData.times) {
const timeSplit = timeRangeEntry.split("-");
if (timeSplit.length !== 2) {
console.debug(`Failed to parse time range: ${timeRangeEntry}`);
continue;
}
const startTime = timeSplit[0].split(":");
const endTime = timeSplit[1].split(":");
times.push(new TimeRange(
parseInt(startTime[0]),
parseInt(startTime[1]),
parseInt(endTime[0]),
parseInt(endTime[1])
));
}
const nearestAetheryte = this.findNearestAetheryte(nodeData?.position?.zone, nodeData?.position?.x, nodeData.position?.y);
if (nearestAetheryte === null) {
console.debug(`Failed to find nearest aetheryte for node: ${JSON.stringify(nodeData)}`);
continue;
}
this.nodes.push(new Node(
job,
nodeType,
nodeData.position,
times,
items,
nearestAetheryte
)
)
}
},
})
</script>
<style scoped lang="scss">
nav {
background-color: #1f1f1f;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-inline: 1rem;
.current-eorzea-time {
font-size: 3rem;
}
.top-bar-menu {
display: flex;
gap: 1rem;
flex-direction: row;
}
.filters-button {
display: flex;
padding: 0.5rem;
border: 1px solid #fff;
background-color: transparent;
cursor: pointer;
width: max-content;
white-space: nowrap;
&.active {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
footer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 1rem 0.25rem;
gap: 0.5rem;
p {
margin: 0;
}
}
</style>
-22
View File
@@ -1,22 +0,0 @@
* {
box-sizing: border-box;
font-family: 'Roboto', sans-serif;
font-size: 16px;
font-weight: 400;
color: #eaeaea;
}
body {
background-color: #2a2a2a;
margin: 0;
padding: 0;
}
#app {
max-width: 1280px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-weight: normal;
}
-241
View File
@@ -1,241 +0,0 @@
<template>
<section>
<details open>
<summary>Level of items</summary>
<section>
<label>
<span>Minimum level</span>
<input
type="number"
placeholder="1"
:min="1"
:max="filters.maxLevel"
v-model="filters.minLevel"
@focusout="(e: FocusEvent) => {
const target = e.target as HTMLInputElement;
const numberValue = parseInt(target.value);
if (filters.maxLevel && numberValue > filters.maxLevel) {
filters.maxLevel = filters.minLevel;
}
}"
>
</label>
<label>
<span>Maximum level</span>
<input
type="number"
placeholder="100"
:min="filters.minLevel"
:max="100"
v-model="filters.maxLevel"
@focusout="(e: FocusEvent) => {
const target = e.target as HTMLInputElement;
const numberValue = parseInt(target.value);
if (filters.minLevel && numberValue < filters.minLevel) {
filters.minLevel = filters.maxLevel;
}
}"
>
</label>
</section>
</details>
<details open>
<summary>Jobs</summary>
<section>
<label class="horizontal">
<span>Botanist</span>
<input
type="checkbox"
:checked="filters.jobs.has(Job.BOTANIST)"
@change="(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
filters.jobs.add(Job.BOTANIST);
} else {
filters.jobs.delete(Job.BOTANIST);
}
}"
>
</label>
<label class="horizontal">
<span>Miner</span>
<input
type="checkbox"
:checked="filters.jobs.has(Job.MINER)"
@change="(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
filters.jobs.add(Job.MINER);
} else {
filters.jobs.delete(Job.MINER);
}
}"
>
</label>
</section>
</details>
<details open>
<summary>Node type</summary>
<section>
<label class="horizontal">
<span>Unspoiled</span>
<input
type="checkbox"
:checked="filters.nodeTypes.has(NodeType.UNSPOILED)"
@change="(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
filters.nodeTypes.add(NodeType.UNSPOILED);
} else {
filters.nodeTypes.delete(NodeType.UNSPOILED);
}
}"
>
</label>
<label class="horizontal">
<span>Legendary</span>
<input
type="checkbox"
:checked="filters.nodeTypes.has(NodeType.LEGENDARY)"
@change="(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
filters.nodeTypes.add(NodeType.LEGENDARY);
} else {
filters.nodeTypes.delete(NodeType.LEGENDARY);
}
}"
>
</label>
</section>
</details>
</section>
</template>
<script lang="ts">
import {defineComponent} from "vue";
import Filters from "@/util/Filters";
import {Job} from "@/enums/Job";
import {NodeType} from "@/enums/NodeType";
export default defineComponent({
name: "FiltersMenu",
computed: {
NodeType() {
return NodeType
},
Job() {
return Job
}
},
emits: ['update:filters'],
data: () => ({
filters: new Filters(),
}),
watch: {
filters: {
handler(newFilters) {
const filters = new Filters(newFilters);
window.localStorage.setItem("filters", filters.serialize());
},
deep: true,
},
minLevel(newValue: string) {
const numberValue = parseInt(newValue);
return isNaN(numberValue) ? 1 : numberValue;
},
maxLevel(newValue: string) {
const numberValue = parseInt(newValue);
return isNaN(numberValue) ? 100 : numberValue;
},
},
mounted() {
const savedFilters = window.localStorage.getItem("filters");
if (!savedFilters) return;
const parsedFilters = JSON.parse(savedFilters);
this.filters = new Filters(parsedFilters);
},
});
</script>
<style scoped lang="scss">
section {
display: flex;
flex-direction: column;
gap: 1rem;
padding-block: 1rem;
padding-inline: 0.25rem;
details {
background-color: #1f1f1f;
display: flex;
flex-direction: column;
border-radius: 0.25rem;
padding: 1rem;
gap: 0.5rem;
position: relative;
&:before {
position: absolute;
right: 1rem;
top: 1rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
justify-content: center;
align-content: center;
content: "▶";
pointer-events: none;
rotate: 90deg;
transition: rotate 0.25s;
}
&[open] {
&:before {
rotate: 270deg;
}
}
summary {
cursor: pointer;
color: white;
border: none;
border-radius: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.25rem;
}
section {
display: flex;
flex-direction: row;
gap: 2rem;
flex-wrap: wrap;
label {
display: flex;
gap: 0.5rem;
flex-direction: column;
&.horizontal {
flex-direction: row;
gap: 0.2rem;
}
input[type="number"] {
padding: 0.5rem;
border: 1px solid #fff;
background-color: transparent;
color: white;
border-radius: 0.25rem;
width: 5rem;
text-align: start;
}
}
}
}
}
</style>
-163
View File
@@ -1,163 +0,0 @@
<template>
<article
class="node"
:class="{active: gatheringNode.isActive(eorzeaTime)}"
>
<div class="timer">
{{
gatheringNode.isActive(eorzeaTime) ? 'Active' : prettyTimer(gatheringNode.getSecondsToNextActiveTime(eorzeaTime))
}}
</div>
<div class="job">
<div class="icon">
<img
:alt="gatheringNode.job"
:src="`https://xivapi.com/cj/1/${gatheringNode.job}.png`"
:title="gatheringNode.job"
draggable="false"
>
</div>
</div>
<div class="aetheryte">
<span class="icon">
<img
src="https://xivapi.com/img-misc/mappy/aetheryte.png"
alt="Aetheryte icon"
draggable="false"
>
</span>
<div class="info">
<span>{{ zones[gatheringNode.nearestAetheryte.position.zone]?.name?.en }}</span>
<span>{{ gatheringNode.nearestAetheryte.name.en }}</span>
<span>{{ gatheringNode.nearestAetheryte.position.x.toFixed(1) }}, {{ gatheringNode.nearestAetheryte.position.y.toFixed(1) }}</span>
</div>
</div>
<div class="items">
<span
v-for="item in gatheringNode.items"
:key="item.name"
>
{{ item.name }} (lv. {{ item.level }})
</span>
</div>
</article>
</template>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import EorzeaTime from "@/util/EorzeaTime";
import Node from "@/entities/Node";
import Zone from "@/entities/Zone";
export default defineComponent({
name: "GatheringNode",
props: {
gatheringNode: {
type: Object as PropType<Node>,
required: true
},
eorzeaTime: {
type: Object as PropType<EorzeaTime>,
required: true
},
zones: {
type: Object as PropType<{ [key: string]: Zone }>,
required: true
},
},
methods: {
prettyTimer(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
}
},
});
</script>
<style scoped lang="scss">
@keyframes pulsing {
0% {background-color: rgba(255,255,255, 0.05);}
50% {background-color: rgba(255,255,255, 0.075);}
100% {background-color: rgba(255,255,255, 0.05);}
}
.node {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
min-height: 6rem;
border: 1px solid #fff;
padding: 0.5rem;
border-radius: 0.25rem;
&.active {
animation: infinite pulsing 6s;
}
.timer {
min-width: 7rem;
font-size: 2rem;
display: flex;
justify-content: center;
align-items: center;
}
.job {
display: flex;
justify-content: center;
align-items: center;
.icon {
width: 3rem;
height: 3rem;
img {
width: 100%;
height: 100%;
}
}
}
.aetheryte {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 0.25rem;
font-size: 1.5rem;
border-radius: 0.75rem;
padding: 0.35rem 1rem;
background-color: rgba(0, 0, 0, 0.2);
.icon {
width: 3rem;
img {
width: 100%;
height: 100%;
object-fit: fill;
}
}
.info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: start;
gap: 0.1rem;
}
}
.items {
display: flex;
flex-direction: column;
justify-content: center;
align-items: start;
gap: 0.1rem;
}
}
</style>
-119
View File
@@ -1,119 +0,0 @@
<template>
<div class="node-list">
<GatheringNode
v-for="node in displayNodes"
:key="`${node.location.x}-${node.location.y}-${node.location.zone}`"
:gathering-node="node as Node"
:eorzea-time="eorzeaTime"
:zones="zones"
/>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import EorzeaTime from "../util/EorzeaTime";
import Node from "@/entities/Node";
import GatheringNode from "@/components/GatheringNode.vue";
import Zone from "@/entities/Zone";
import Filters from "@/util/Filters";
export default defineComponent(
{
name: "SortedNodeList",
components: {GatheringNode},
props: {
nodes: {
type: Array as PropType<Node[]>,
required: true
},
eorzeaTime: {
type: Object as PropType<EorzeaTime>,
required: true
},
zones: {
type: Object as PropType<{ [key: string]: Zone }>,
required: true
},
},
watch: {
nodes: {
handler(newNodes: Node[]) {
this.filterNodes(newNodes);
},
deep: true
},
displayNodes: {
handler() {
this.sortListByTime();
}
},
eorzeaTime: {
handler(newValue, oldValue) {
if (oldValue === undefined) return;
if (newValue?.getMinutes() === oldValue?.getMinutes()) return;
this.sortListByTime();
}
}
},
data: () => ({
displayNodes: [] as Node[],
}),
methods: {
sortListByTime() {
this.displayNodes.sort((a, b): number => {
const aSeconds = a.getSecondsToNextActiveTime(this.eorzeaTime);
const bSeconds = b.getSecondsToNextActiveTime(this.eorzeaTime);
if (aSeconds === bSeconds) return 1;
return aSeconds - bSeconds;
});
},
filterNodes(nodes: Node[] = []) {
let filters: Filters | null = null;
let filtersString = window.localStorage.getItem("filters");
if (filtersString === null) {
window.localStorage.setItem("filters", JSON.stringify(new Filters()));
filtersString = window.localStorage.getItem("filters");
}
if (filtersString === null) {
console.error("Failed to get filters from local storage!");
return;
}
const parsedFilters = JSON.parse(filtersString);
filters = new Filters(parsedFilters);
this.displayNodes = nodes.filter((node) => {
let shouldDisplay = false;
if (filters) {
if (!filters.jobs.has(node.job)) return false;
if (!filters.nodeTypes.has(node.nodeType)) return false;
}
for (const item of node.items) {
if (filters && item.level >= filters.minLevel && item.level <= filters.maxLevel) {
shouldDisplay = true;
break;
}
}
return shouldDisplay;
});
},
},
mounted() {
this.filterNodes(this.nodes);
},
}
);
</script>
<style scoped lang="scss">
.node-list {
display: flex;
flex-direction: column;
gap: 0.33rem;
padding-block: 0.5rem;
padding-inline: 0.25rem;
}
</style>
-37
View File
@@ -1,37 +0,0 @@
<template>
<div class="github-logo" aria-label="Github logo" title="GitHub">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<g :fill="color">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"/>
<path
d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm2.446 2.729c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zM31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm3.261 3.361c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm4.5 1.951c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm4.943.361c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm4.598-.782c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"/>
</g>
</svg>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
export default defineComponent({
name: "GithubLogo",
props: {
color: {
type: String,
default: "#181616"
},
size: {
type: String,
default: "24px"
}
},
})
</script>
<style scoped lang="scss">
.github-logo {
width: v-bind(size);
height: v-bind(size);
}
</style>
-16
View File
@@ -1,16 +0,0 @@
export default class Aetheryte {
readonly position: { x: number, y: number, zone: string };
readonly name: {
en: string,
}
constructor(
data: {position: {x: number, y: number, zone: string}, name: {en: string}}
) {
this.position = data.position;
this.name = data.name;
}
}
-14
View File
@@ -1,14 +0,0 @@
export default class Item {
readonly id: string;
readonly name: string;
readonly level: number;
constructor(id: string, data: {[key: string]: number | string | undefined}) {
this.id = id;
this.name = data?.name as string;
this.level = data?.level as number;
}
}
-63
View File
@@ -1,63 +0,0 @@
import {Job} from "@/enums/Job";
import {NodeType} from "@/enums/NodeType";
import Item from "./Item";
import Aetheryte from "./Aetheryte";
import TimeRange from "./TimeRange";
import EorzeaTime from "../util/EorzeaTime";
export default class Node {
readonly job: Job;
readonly nodeType: NodeType;
readonly location: { x: number, y: number, zone: string };
readonly times: Array<TimeRange>;
readonly nearestAetheryte: Aetheryte;
readonly items: Item[];
constructor(
job: Job,
nodeType: NodeType,
location: { x: number, y: number, zone: string },
times: Array<TimeRange>,
items: Item[],
nearestAetheryte: Aetheryte,
) {
this.job = job;
this.nodeType = nodeType;
this.location = location;
this.times = times;
this.items = items;
this.nearestAetheryte = nearestAetheryte;
items.sort((a, b) => b.level - a.level);
}
isActive(eorzeaTime: EorzeaTime): boolean {
for (const timeRange of this.times) {
if (timeRange.isWithinTimeFrame(eorzeaTime.getHours(), eorzeaTime.getMinutes())) return true;
}
return false;
}
getCountdownToActive(eorzeaTime: EorzeaTime): number {
let countdown: number = Infinity;
for (const timeRange of this.times) {
const nextTimeFrame: number = timeRange.getNextTimeFrame(eorzeaTime);
if (nextTimeFrame < countdown) countdown = nextTimeFrame;
}
return countdown;
}
getNextActiveTime(eorzeaTime: EorzeaTime): EorzeaTime {
let countdownTimeStamp: number = Infinity;
for (const timeRange of this.times) {
const nextTimeFrame: number = timeRange.getNextTimeFrame(eorzeaTime);
if (nextTimeFrame < countdownTimeStamp) countdownTimeStamp = nextTimeFrame;
}
return EorzeaTime.fromEorzeaTime(new Date(this.getCountdownToActive(eorzeaTime)));
}
getSecondsToNextActiveTime(eorzeaTime: EorzeaTime): number {
return Math.floor((this.getNextActiveTime(eorzeaTime).realDate.getTime() - eorzeaTime.realDate.getTime()) / 1000);
}
}
-32
View File
@@ -1,32 +0,0 @@
import EorzeaTime from "../util/EorzeaTime";
export default class TimeRange {
private readonly from: [number, number];
private readonly to: [number, number];
constructor(fromHour: number, fromMinute: number, toHour: number, toMinute: number) {
this.from = [fromHour, fromMinute];
this.to = [toHour, toMinute];
}
public isWithinTimeFrame(hour: number, minute: number): boolean {
return (
this.from[0] < hour || this.from[0] == hour && this.from[1] <= minute)
&& (hour < this.to[0] || hour == this.to[0] && minute <= this.to[1]
);
}
/**
* Returns a timestamp when the time range will be active again
*/
public getNextTimeFrame(eorzeaTimeFrom: EorzeaTime): number {
const targetDate = new Date(eorzeaTimeFrom.eorzeaDate.getTime());
targetDate.setUTCHours(this.from[0], 0, 0, 0);
if (eorzeaTimeFrom.getHours() >= this.to[0]) {
targetDate.setUTCHours(this.from[0] + 24);
}
return targetDate.getTime();
}
}
-21
View File
@@ -1,21 +0,0 @@
import Aetheryte from "@/entities/Aetheryte";
export default class Zone {
name: {
en: string,
}
aetherytes: Array<Aetheryte> = [];
constructor(data: {name: {en: string}, aetherytes: Array<{position: {x: number, y: number, zone: string}, name: {en: string}}>}) {
this.name = {
en: data.name.en
};
if (!Array.isArray(data.aetherytes)) return;
for (const aetheryte of data.aetherytes) {
this.aetherytes.push(new Aetheryte(aetheryte));
}
}
}
-15
View File
@@ -1,15 +0,0 @@
export enum Job {
BOTANIST = "botanist",
MINER = "miner",
}
export function jobFromString(str: string): Job | null {
switch (str.toLowerCase()) {
case "botanist":
return Job.BOTANIST;
case "miner":
return Job.MINER;
default:
return null;
}
}
-15
View File
@@ -1,15 +0,0 @@
export enum NodeType {
UNSPOILED = "unspoiled",
LEGENDARY = "legendary",
}
export function nodeTypeFromString(str: string): NodeType | null {
switch (str.toLowerCase()) {
case "unspoiled":
return NodeType.UNSPOILED;
case "legendary":
return NodeType.LEGENDARY;
default:
return null;
}
}
-6
View File
@@ -1,6 +0,0 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
-62
View File
@@ -1,62 +0,0 @@
export default class EorzeaTime {
/**
* The real life date
*/
readonly realDate: Date;
/**
* The Eorzean date
* @private
*/
readonly eorzeaDate: Date;
public constructor(realDate: Date = new Date()) {
this.realDate = realDate;
this.eorzeaDate = new Date(realDate.getTime() * (3600 / 175));
}
/**
* Returns Eorzean hours
*/
getHours(): number {
return this.eorzeaDate.getUTCHours();
}
/**
* Returns Eorzean minutes
*/
getMinutes(): number {
return this.eorzeaDate.getUTCMinutes();
}
/**
* Returns Eorzean seconds
*/
getSeconds(): number {
return this.eorzeaDate.getUTCSeconds();
}
/**
* Returns Eorzean timestamp
*/
getTime(): number {
return this.eorzeaDate.getTime();
}
getPrettyTime(): string {
const hours: string = this.getHours().toString();
let minutes: string = this.getMinutes().toString();
if (minutes.length === 1) minutes = '0' + minutes;
return `${hours}:${minutes}`;
}
public static fromRealTime(realDate: Date): EorzeaTime {
return new EorzeaTime(realDate);
}
public static fromEorzeaTime(eorzeaDate: Date): EorzeaTime {
return new EorzeaTime(new Date(eorzeaDate.getTime() / (3600 / 175)));
}
}
-50
View File
@@ -1,50 +0,0 @@
import {Job, jobFromString} from "@/enums/Job";
import {NodeType, nodeTypeFromString} from "@/enums/NodeType";
export default class Filters {
minLevel: number;
maxLevel: number;
jobs: Set<Job> = new Set();
nodeTypes: Set<NodeType> = new Set();
constructor(
data?: {
minLevel?: number,
maxLevel?: number,
jobs?: string[],
nodeTypes?: string[],
},
) {
this.minLevel = data?.minLevel || 91;
this.maxLevel = data?.maxLevel || 100;
const jobData = data?.jobs || [Job.BOTANIST, Job.MINER];
for (const job of jobData) {
const parsedJob = jobFromString(job);
if (!parsedJob) continue;
this.jobs.add(parsedJob);
}
const nodeTypeData = data?.nodeTypes || [
NodeType.UNSPOILED,
];
for (const nodeType of nodeTypeData) {
const parsedNodeType = nodeTypeFromString(nodeType);
if (!parsedNodeType) continue;
this.nodeTypes.add(parsedNodeType);
}
}
serialize(): string {
return JSON.stringify({
minLevel: this.minLevel,
maxLevel: this.maxLevel,
jobs: Array.from(this.jobs).map(job => job),
nodeTypes: Array.from(this.nodeTypes).map(nodeType => nodeType),
});
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"allowJs": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
}
-18
View File
@@ -1,18 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import eslintPlugin from 'vite-plugin-eslint'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
eslintPlugin(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})