diff --git a/packages/models/package.json b/packages/models/package.json index 9d8ecb02..09a57f13 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -20,5 +20,8 @@ "power-putty-io": "^1.0.1", "power-putty-test": "^1.0.0", "typescript": "5.x.x" + }, + "devDependencies": { + "mocha": "^11.0.1" } } diff --git a/packages/models/src/Grade.ts b/packages/models/src/Grade.ts index efa60cdf..74d25d5d 100644 --- a/packages/models/src/Grade.ts +++ b/packages/models/src/Grade.ts @@ -45,106 +45,11 @@ class Grade { } } +export default Grade; + /** - * GRADE DATA BELOW + * Grade parsers */ - -type TupleToRecord = { - [K in T[number]]: V; // or any other type -}; - -// We are focusing on bouldering, so start with v scale and will adapt others -// into this value system. To start with, normalize as base (V+1) * 10 -// Plus/minus grades can add/subtract 1 -// Slash grades can average the two grades. -const VGrade = [ - "VB", - "V0", - "V1", - "V2", - "V3", - "V4", - "V5", - "V6", - "V7", - "V8", - "V9", - "V10", - "V11", - "V12", - "V13", - "V14", - "V15", - "V16", - "V17", -] as const; -const vGrades: TupleToRecord = { - VB: 0, - V0: 10, - V1: 20, - V2: 30, - V3: 40, - V4: 50, - V5: 60, - V6: 70, - V7: 80, - V8: 90, - V9: 100, - V10: 110, - V11: 120, - V12: 130, - V13: 140, - V14: 150, - V15: 160, - V16: 170, - V17: 180, -}; - -export const grades: Record> = { - [GradingSystemType.V]: vGrades, - // Sourced from https://en.wikipedia.org/wiki/Grade_(climbing)#Comparison_bouldering - [GradingSystemType.YDS]: { - "5.1": -6, - "5.2": -5, - "5.3": -4, - "5.4": -3, - "5.5": -2, - "5.6": -1, - "5.7": vGrades.VB, - "5.8": vGrades.V0 - 1, - "5.9": vGrades.V0, - "5.10": vGrades.V0 + 1, - "5.10a": vGrades.V0 + 1, // 5.10a == 5.10 - "5.10b": vGrades.V0 + 2, - "5.10c": vGrades.V1, - "5.10d": (vGrades.V1 + vGrades.V2) / 2, // V1-2 - "5.11": vGrades.V2, - "5.11a": vGrades.V2, // 5.11a == 5.11 - "5.11b": vGrades.V2 + 1, - "5.11c": vGrades.V3, - "5.11d": vGrades.V3 + 1, - "5.12": vGrades.V4, - "5.12a": vGrades.V4, // 5.12a == 5.12 - "5.12b": vGrades.V5, - "5.12c": vGrades.V6, - "5.12d": vGrades.V7, - "5.13": vGrades.V8, - "5.13a": vGrades.V8, // 5.13a == 5.13 - "5.13b": vGrades.V8 + 1, - "5.13c": vGrades.V9, - "5.13d": vGrades.V10, - "5.14": vGrades.V11, - "5.14a": vGrades.V11, // 5.14a == 5.14 - "5.14b": vGrades.V12, - "5.14c": vGrades.V13, - "5.14d": vGrades.V14, - "5.15": vGrades.V15, - "5.15a": vGrades.V15, // 5.15a == 5.15 - "5.15b": vGrades.V16, - "5.15c": vGrades.V17, - }, -}; - export function parseRaw(raw: string): IGrade { if (raw.length === 0) { throw new RangeError("Invalid grade ''"); @@ -236,7 +141,7 @@ export function parseRawVScore(raw: string, flexible: boolean = false): number { plus = true; } // Should be a basic grade now - let score = grades[GradingSystemType.V]![raw]; + let score = gradeValues[GradingSystemType.V]![raw]; if (score === undefined) { throw new RangeError(`Invalid V grade '${raw}'`); } @@ -276,7 +181,7 @@ export function parseRawYDSScore( plus = true; } // Should be a basic grade now - let score = grades[GradingSystemType.YDS]![raw]; + let score = gradeValues[GradingSystemType.YDS]![raw]; if (score === undefined) { throw new RangeError(`Invalid YDS grade '${raw}'`); } @@ -290,4 +195,110 @@ export function parseRawYDSScore( } export const parseRawYDS = splitter(GradingSystemType.YDS, parseRawYDSScore); -export default Grade; + +/** Grade data */ +type TupleToRecord = { + [K in T[number]]: V; // or any other type +}; + +// We are focusing on bouldering, so start with v scale and will adapt others +// into this value system. To start with, normalize as base (V+1) * 10 +// Plus/minus grades can add/subtract 1 +// Slash grades can average the two grades. +const VGrade = [ + "VB", + "V0", + "V1", + "V2", + "V3", + "V4", + "V5", + "V6", + "V7", + "V8", + "V9", + "V10", + "V11", + "V12", + "V13", + "V14", + "V15", + "V16", + "V17", +] as const; +const vGradeValues: TupleToRecord = { + VB: 0, + V0: 10, + V1: 20, + V2: 30, + V3: 40, + V4: 50, + V5: 60, + V6: 70, + V7: 80, + V8: 90, + V9: 100, + V10: 110, + V11: 120, + V12: 130, + V13: 140, + V14: 150, + V15: 160, + V16: 170, + V17: 180, +}; + +export const gradeValues: Record> = { + [GradingSystemType.V]: vGradeValues, + // Sourced from https://en.wikipedia.org/wiki/Grade_(climbing)#Comparison_bouldering + [GradingSystemType.YDS]: { + "5.1": -6, + "5.2": -5, + "5.3": -4, + "5.4": -3, + "5.5": -2, + "5.6": -1, + "5.7": vGradeValues.VB, + "5.8": vGradeValues.V0 - 1, + "5.9": vGradeValues.V0, + "5.10": vGradeValues.V0 + 1, + "5.10a": vGradeValues.V0 + 1, // 5.10a == 5.10 + "5.10b": vGradeValues.V0 + 2, + "5.10c": vGradeValues.V1, + "5.10d": (vGradeValues.V1 + vGradeValues.V2) / 2, // V1-2 + "5.11": vGradeValues.V2, + "5.11a": vGradeValues.V2, // 5.11a == 5.11 + "5.11b": vGradeValues.V2 + 1, + "5.11c": vGradeValues.V3, + "5.11d": vGradeValues.V3 + 1, + "5.12": vGradeValues.V4, + "5.12a": vGradeValues.V4, // 5.12a == 5.12 + "5.12b": vGradeValues.V5, + "5.12c": vGradeValues.V6, + "5.12d": vGradeValues.V7, + "5.13": vGradeValues.V8, + "5.13a": vGradeValues.V8, // 5.13a == 5.13 + "5.13b": vGradeValues.V8 + 1, + "5.13c": vGradeValues.V9, + "5.13d": vGradeValues.V10, + "5.14": vGradeValues.V11, + "5.14a": vGradeValues.V11, // 5.14a == 5.14 + "5.14b": vGradeValues.V12, + "5.14c": vGradeValues.V13, + "5.14d": vGradeValues.V14, + "5.15": vGradeValues.V15, + "5.15a": vGradeValues.V15, // 5.15a == 5.15 + "5.15b": vGradeValues.V16, + "5.15c": vGradeValues.V17, + }, +}; +export const grades: Record> = Object.keys(GradingSystemType).reduce( + (bySystem, system) => ({ + ...bySystem, + [system]: Object.keys(gradeValues[system as GradingSystemType]).reduce((agg, v) => { + agg[v] = Grade.build(v); + return agg; + }, {} as Record), + }), + {} as Record>, +); diff --git a/packages/models/src/Topo.ts b/packages/models/src/Topo.ts new file mode 100644 index 00000000..5f6110fa --- /dev/null +++ b/packages/models/src/Topo.ts @@ -0,0 +1,19 @@ +/** + * A Topo represents drawn diagrams on top of photo, intending + * to show route lines, sections of a wall, or a topo of a crag + * showing areas from afar. + * + * Features: + * * Vector based canvas editor to generate topos + * * A Topo can be associated with any Photoable, and also have + * additional relationships with crags, areas, boulders, or routes. + * * Crag -- topos annotate areas in the crag + * * Area -- topos annotate boulders in an area + * * Boulder -- topos annotate the routes on a boulder. + * * Route -- topo annotates a single route on a single image. + * * A Topo will have various tools for generating diagrams + * * Path -- basic paths, or even a closed polygon + * * Icons -- signal hold types, or mark flexing holds, etc. + * * Labels -- while associations will help us relate which path applies to what, + * labels can bake that into the image. + */ \ No newline at end of file diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 8105b5f7..f98faf64 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -8,18 +8,15 @@ export { isCoordinateLiteral, type ICoordinate, type ICoordinateLiteral, - type ICoordinateTuple, + type ICoordinateTuple } from "./Coordinate.js"; export { default as CoordinateOptional, - type ICoordinateOptional, + type ICoordinateOptional } from "./CoordinateOptional.js"; export { default as Crag, type ICrag } from "./Crag.js"; export { - default as Grade, - GradingSystemType, - grades, - type IGrade, + default as Grade, grades, GradingSystemType, type IGrade } from "./Grade.js"; export { default as Line, type ILine } from "./Line.js"; export { default as Photo, type IPhoto } from "./Photo.js"; @@ -28,3 +25,4 @@ export { default as Polygon, type IPolygon } from "./Polygon.js"; export { default as Route, type IRoute } from "./Route.js"; export { default as Trail, type ITrail } from "./Trail.js"; export { default as Upload, type IUpload } from "./Upload.js"; + diff --git a/packages/ui/app/_components/search/DifficultySlider.tsx b/packages/ui/app/_components/search/DifficultySlider.tsx new file mode 100644 index 00000000..2c34e0c2 --- /dev/null +++ b/packages/ui/app/_components/search/DifficultySlider.tsx @@ -0,0 +1,37 @@ +"use client"; +import { searchParamsParsers } from "@/app/_components/search/searchParams"; +import useQueryState from "@/app/_util/useQueryState"; +import { Slider } from "@mui/material"; +import { grades } from "models"; + +export default function DifficultySlider() { + const [difficultyMin, setDifficultyMin] = useQueryState( + "vMin", + searchParamsParsers.vMin + ); + const [difficultyMax, setDifficultyMax] = useQueryState( + "vMax", + searchParamsParsers.vMax + ); + // VB - V17 + const marks = Object.values(grades.V).map((g) => ({ + value: g.value, + label: g.raw, + })); + return ( + { + const values = _values as [number, number]; + setDifficultyMin(values[0]); + setDifficultyMax(values[1]); + }} + valueLabelDisplay="off" + valueLabelFormat={(_, i) => marks[i].label} + /> + ); +} diff --git a/packages/ui/app/_components/search/SearchFilters.tsx b/packages/ui/app/_components/search/SearchFilters.tsx index 8b31ce05..d3440a3c 100644 --- a/packages/ui/app/_components/search/SearchFilters.tsx +++ b/packages/ui/app/_components/search/SearchFilters.tsx @@ -1,3 +1,4 @@ +import DifficultySlider from "@/app/_components/search/DifficultySlider"; import SearchShadeCheck from "@/app/_components/search/SearchShadeCheck"; import SearchTypeSelect from "@/app/_components/search/SearchTypeSelect"; import SunHoursField from "@/app/_components/search/SunHoursField"; @@ -28,6 +29,7 @@ export default function SearchFilters(props: Props) { + diff --git a/packages/ui/app/_components/search/searchParams.ts b/packages/ui/app/_components/search/searchParams.ts index b1215f3f..73a3e9be 100644 --- a/packages/ui/app/_components/search/searchParams.ts +++ b/packages/ui/app/_components/search/searchParams.ts @@ -16,6 +16,8 @@ export const searchParamsParsers = { shade: parseAsBoolean.withDefault(false).withOptions(defaultOptions), // TODO: Nicety, get users' current hour as default shadeHour: parseAsInteger.withDefault(12).withOptions(defaultOptions), + vMin: parseAsInteger.withDefault(0).withOptions(defaultOptions), + vMax: parseAsInteger.withDefault(0).withOptions(defaultOptions), }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/packages/ui/app/api/_actions/search.ts b/packages/ui/app/api/_actions/search.ts index 41be6647..ba7f1c72 100644 --- a/packages/ui/app/api/_actions/search.ts +++ b/packages/ui/app/api/_actions/search.ts @@ -1,8 +1,7 @@ import getNormalizedSunValueForRoute from "@/app/_components/sun/getNormalizedSunValueForRoute"; -import { getDataSource } from "@/db"; import CragRepository from "@/db/repos/CragRepository"; import { reduce } from "lodash-es"; -import { ICrag, Route } from "models"; +import { Grade, ICrag, Route } from "models"; import { cache } from "react"; import "server-only"; @@ -12,6 +11,8 @@ export interface SearchParams { type: SearchResultType; shade: boolean; shadeHour: number | null; + vMin: number; + vMax: number; } interface Searchable { @@ -34,8 +35,6 @@ export type SearchResult = Searchable & { }; const search = cache(async (params: SearchParams) => { - const ds = await getDataSource(); - // For simplicity, just get all routes in the crag and filter them in memory // TODO: Migrate to database as perf requires const crag = await CragRepository.findOneT({ @@ -64,11 +63,13 @@ const search = cache(async (params: SearchParams) => { const filterSearch = searchMatcher(params.search); const filterType = typeMatcher(params.type); const filterSun = sunMatcher(params.shade, params.shadeHour); + const filterDifficulty = difficultyMatcher(params.vMin, params.vMax); return getSearchableEntitiesForCrag(crag).filter((thisEntity) => { return ( filterType(thisEntity) && filterSearch(thisEntity) && - filterSun(thisEntity) + filterSun(thisEntity) && + filterDifficulty(thisEntity) ); }); }); @@ -135,6 +136,18 @@ const typeMatcher: GetMatcher = (type: SearchResultType) => { }; }; +const difficultyMatcher: GetMatcher = (min: number, max: number) => { + if (!(min >= 0 && max <= 170 && min < max)) { + return () => true; + } + return (s) => { + if (s.type !== "route") return true; + + const value = Grade.build(s.gradeRaw!).value; + return s.type === "route" && value >= min && value <= max; + }; +}; + const sunMatcher: GetMatcher = (apply: boolean = false, givenHour?: number) => { if (!apply) { return (s) => true; diff --git a/packages/ui/app/crags/[cragId]/search/page.tsx b/packages/ui/app/crags/[cragId]/search/page.tsx index c937d965..b7119527 100644 --- a/packages/ui/app/crags/[cragId]/search/page.tsx +++ b/packages/ui/app/crags/[cragId]/search/page.tsx @@ -7,12 +7,10 @@ import { } from "@/app/api/_actions/search"; import { notFound } from "next/navigation"; -export default async function page( - props: { - params: Promise<{ cragId: string }>; - searchParams: Promise>; - } -) { +export default async function page(props: { + params: Promise<{ cragId: string }>; + searchParams: Promise>; +}) { const searchParams = await props.searchParams; const params = await props.params; const crag = await getCrag(params.cragId); @@ -26,6 +24,8 @@ export default async function page( type: asSearchResultType(searchCache.type), shade: searchCache.shade, shadeHour: searchCache.shadeHour, + vMin: searchCache.vMin, + vMax: searchCache.vMax, }); return ; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dfcf508..31c6cb6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,10 @@ importers: typescript: specifier: 5.x.x version: 5.4.3 + devDependencies: + mocha: + specifier: ^11.0.1 + version: 11.0.1 packages/ui: dependencies: