Compare commits

..

7 Commits

265 changed files with 1208 additions and 4502 deletions

@ -1,11 +1,10 @@
from PyWeather.weather.stations.davis import VantagePro
import logging import logging
import time
import mariadb import mariadb
import serial.tools.list_ports import serial.tools.list_ports
import gc
import time
from pprint import pprint
from PyWeather.weather.stations.davis import VantagePro
logging.basicConfig(filename="Stations.log", logging.basicConfig(filename="Stations.log",
format='%(asctime)s %(message)s', format='%(asctime)s %(message)s',
@ -13,37 +12,10 @@ logging.basicConfig(filename="Stations.log",
logger = logging.getLogger('davis_api') logger = logging.getLogger('davis_api')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
def write_data(device, station, send=True): console_handler.setLevel(logging.DEBUG)
try: console_handler.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
#device.parse() logger.addHandler(console_handler)
data = device.fields
print(data)
if len(data) < 1:
return
else:
print(data)
fields = ['BarTrend', 'CRC', 'DateStamp', 'DewPoint', 'HeatIndex', 'ETDay', 'HeatIndex',
'HumIn', 'HumOut', 'Pressure', 'RainDay', 'RainMonth', 'RainRate', 'RainStorm',
'RainYear', 'SunRise', 'SunSet', 'TempIn', 'TempOut', 'WindDir', 'WindSpeed',
'WindSpeed10Min']
if send:
placeholders = ', '.join(['%s'] * len(fields))
field_names = ', '.join(fields)
sql = f"INSERT INTO weather_data ({field_names}) VALUES ({placeholders})"
values = [data[field] for field in fields]
cursor.execute(sql, values)
conn.commit()
else:
pprint(data)
del data
del fields
gc.collect()
except Exception as e:
logger.error(str(e))
raise e
try: try:
conn = mariadb.connect( conn = mariadb.connect(
@ -57,23 +29,25 @@ try:
except mariadb.Error as e: except mariadb.Error as e:
logger.error('DB_ERR: ' + str(e)) logger.error('DB_ERR: ' + str(e))
raise e raise e
while True:
try:
ports = serial.tools.list_ports.comports()
available_ports = {}
try: for port in ports:
ports = serial.tools.list_ports.comports() if port.serial_number == '0001':
available_ports = {} available_ports[port.name] = port.vid
for port in ports: devices = [VantagePro(port) for port in available_ports.keys()]
if port.serial_number == '0001': while True:
available_ports[port.name] = port.vid for i in range(1):
if len(devices) != 0:
devices = [VantagePro(port) for port in available_ports.keys()] logger.info(devices)
print(available_ports) else:
while True: raise Exception('Can`t connect to device')
for i in range(len(devices)): time.sleep(60)
print(devices[i].fields) except Exception as e:
#write_data(devices[i], 'st' + str(available_ports[list(available_ports.keys())[i]]), True) logger.error('Device_error' + str(e))
time.sleep(1) time.sleep(60)
except Exception as e:
logger.error('Device_error: ' + str(e))
raise e
# todo переписать под influx, для линухи приколы сделать

189
davisAPI/prediction.py Normal file

@ -0,0 +1,189 @@
from datetime import datetime
from pathlib import Path
import metpy.calc
import numpy as np
import requests
import torch
import xarray as xr
from aurora import AuroraSmall, Batch, Metadata
from metpy.units import units
def get_download_paths(date):
"""Создает список путей для загрузки данных."""
download_path = Path("~/downloads/hres_0.1").expanduser()
downloads = {}
var_nums = {
"2t": "167", "10u": "165", "10v": "166", "msl": "151", "t": "130",
"u": "131", "v": "132", "q": "133", "z": "129", "slt": "043", "lsm": "172",
}
for v in ["2t", "10u", "10v", "msl", "z", "slt", "lsm"]:
downloads[download_path / date.strftime(f"surf_{v}_%Y-%m-%d.grib")] = (
f"https://data.rda.ucar.edu/ds113.1/"
f"ec.oper.an.sfc/{date.year}{date.month:02d}/ec.oper.an.sfc.128_{var_nums[v]}_{v}."
f"regn1280sc.{date.year}{date.month:02d}{date.day:02d}.grb"
)
for v in ["z", "t", "u", "v", "q"]:
for hour in [0, 6, 12, 18]:
prefix = "uv" if v in {"u", "v"} else "sc"
downloads[download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_{hour:02d}.grib")] = (
f"https://data.rda.ucar.edu/ds113.1/"
f"ec.oper.an.pl/{date.year}{date.month:02d}/ec.oper.an.pl.128_{var_nums[v]}_{v}."
f"regn1280{prefix}.{date.year}{date.month:02d}{date.day:02d}{hour:02d}.grb"
)
return downloads, download_path
def download_data(downloads):
"""Скачивает файлы, если они отсутствуют в целевой директории."""
for target, source in downloads.items():
if not target.exists():
print(f"Downloading {source}")
target.parent.mkdir(parents=True, exist_ok=True)
response = requests.get(source)
response.raise_for_status()
with open(target, "wb") as f:
f.write(response.content)
print("Downloads finished!")
def load_surf(v, v_in_file, download_path, date):
"""Загружает переменные поверхностного уровня или статические переменные."""
ds = xr.open_dataset(download_path / date.strftime(f"surf_{v}_%Y-%m-%d.grib"), engine="cfgrib")
data = ds[v_in_file].values[:2]
data = data[None]
return torch.from_numpy(data)
def load_atmos(v, download_path, date, levels):
"""Загружает атмосферные переменные для заданных уровней давления."""
ds_00 = xr.open_dataset(
download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_00.grib"), engine="cfgrib"
)
ds_06 = xr.open_dataset(
download_path / date.strftime(f"atmos_{v}_%Y-%m-%d_06.grib"), engine="cfgrib"
)
ds_00 = ds_00[v].sel(isobaricInhPa=list(levels))
ds_06 = ds_06[v].sel(isobaricInhPa=list(levels))
data = np.stack((ds_00.values, ds_06.values), axis=0)
data = data[None]
return torch.from_numpy(data)
def create_batch(date, levels, downloads, download_path):
"""Создает объект Batch с данными для модели."""
ds = xr.open_dataset(next(iter(downloads.keys())), engine="cfgrib")
batch = Batch(
surf_vars={
"2t": load_surf("2t", "t2m", download_path, date),
"10u": load_surf("10u", "u10", download_path, date),
"10v": load_surf("10v", "v10", download_path, date),
"msl": load_surf("msl", "msl", download_path, date),
},
static_vars={
"z": load_surf("z", "z", download_path, date)[0, 0],
"slt": load_surf("slt", "slt", download_path, date)[0, 0],
"lsm": load_surf("lsm", "lsm", download_path, date)[0, 0],
},
atmos_vars={
"t": load_atmos("t", download_path, date, levels),
"u": load_atmos("u", download_path, date, levels),
"v": load_atmos("v", download_path, date, levels),
"q": load_atmos("q", download_path, date, levels),
"z": load_atmos("z", download_path, date, levels),
},
metadata=Metadata(
lat=torch.from_numpy(ds.latitude.values),
lon=torch.from_numpy(ds.longitude.values),
time=(date.replace(hour=6),),
atmos_levels=levels,
),
)
return batch.regrid(res=0.1)
def create_batch_random(levels: tuple[int], date: tuple):
"""Создает объект Batch с рандомными данными для модели."""
return Batch(
surf_vars={k: torch.randn(1, 2, 17, 32) for k in ("2t", "10u", "10v", "msl")},
static_vars={k: torch.randn(17, 32) for k in ("lsm", "z", "slt")},
atmos_vars={k: torch.randn(1, 2, 4, 17, 32) for k in ("z", "u", "v", "t", "q")},
metadata=Metadata(
lat=torch.linspace(90, -90, 17),
lon=torch.linspace(0, 360, 32 + 1)[:-1],
time=date,
atmos_levels=levels,
),
)
def run_model(batch):
"""Инициализирует модель AuroraSmall и выполняет предсказание."""
model = AuroraSmall()
model.load_checkpoint("microsoft/aurora", "aurora-0.25-small-pretrained.ckpt")
model.eval()
model = model.to("cpu")
with torch.inference_mode():
prediction = model.forward(batch)
return prediction
def get_wind_speed_and_direction(prediction, batch: Batch, lat: float, lon: float):
target_lat = lat
target_lon = lon
lat_idx = torch.abs(batch.metadata.lat - target_lat).argmin()
lon_idx = torch.abs(batch.metadata.lon - target_lon).argmin()
u_values = prediction.atmos_vars["u"][:, :, :, lat_idx, lon_idx]
v_values = prediction.atmos_vars["v"][:, :, :, lat_idx, lon_idx]
wind_speeds=[]
wind_directions=[]
for i in range(u_values.numel()):
u_scalar = u_values.view(-1)[i].item() # Разворачиваем тензор в одномерный и берем элемент
v_scalar = v_values.view(-1)[i].item()
print("u value:", u_scalar)
print("v value:", v_scalar)
u_with_units = u_scalar * units("m/s")
v_with_units = v_scalar * units("m/s")
# Рассчитайте направление и скорость ветра
wind_dir = metpy.calc.wind_direction(u_with_units, v_with_units)
wind_speed = metpy.calc.wind_speed(u_with_units, v_with_units)
wind_speeds.append(wind_speed.magnitude.item())
wind_directions.append(wind_dir.magnitude.item())
return wind_speeds,wind_directions
def wind_direction_to_text(wind_dir_deg):
directions = [
"север", "северо-восток", "восток", "юго-восток",
"юг", "юго-запад", "запад", "северо-запад"
]
idx = int((wind_dir_deg + 22.5) // 45) % 8
return directions[idx]
def main():
levels = (100,)
date1 = datetime(2024, 11, 27, 12)
date2 = datetime(2024, 11, 28, 12)
date_tuple = (date1, date2,)
# downloads, download_path = get_download_paths(date)
# download_data(downloads) # Скачиваем данные, если их нет
# batch_actual = create_batch(date, levels, downloads, download_path)
batch_actual = create_batch_random(levels, date_tuple)
prediction_actual = run_model(batch_actual)
wind_speed_and_direction = get_wind_speed_and_direction(prediction_actual, batch_actual, 50, 20)
return wind_speed_and_direction
if __name__ == "__main__":
main()
print("Prediction completed!")

@ -2811,7 +2811,7 @@
"version": "15.7.13", "version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"dev": true "devOptional": true
}, },
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.16", "version": "6.9.16",
@ -2829,7 +2829,7 @@
"version": "18.3.8", "version": "18.3.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz",
"integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -4262,7 +4262,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true "devOptional": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M0.49,2.99c0-0.13,0.05-0.26,0.15-0.35l2-2c0.2-0.2,0.51-0.2,0.71,0s0.2,0.51,0,0.71L1.71,2.99l1.65,1.65
c0.2,0.2,0.2,0.51,0,0.71s-0.51,0.2-0.71,0l-2-2C0.54,3.25,0.49,3.12,0.49,2.99z"/>
</svg>

Before

(image error) Size: 548 B

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 4 6" style="enable-background:new 0 0 4 6;" xml:space="preserve">
<path d="M3.51,3.01c0,0.13-0.05,0.26-0.15,0.35l-2,2c-0.2,0.2-0.51,0.2-0.71,0c-0.2-0.2-0.2-0.51,0-0.71l1.64-1.64L0.64,1.36
c-0.2-0.2-0.2-0.51,0-0.71s0.51-0.2,0.71,0l2,2C3.46,2.75,3.51,2.88,3.51,3.01z"/>
</svg>

Before

(image error) Size: 558 B

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<path d="M1.23,5.47L3,3.71l1.77,1.77c0.2,0.2,0.51,0.2,0.71,0c0.2-0.2,0.2-0.51,0-0.71L3.71,3l1.77-1.77c0.2-0.2,0.2-0.51,0-0.71
c-0.2-0.2-0.51-0.2-0.71,0L3,2.29L1.23,0.53c-0.2-0.2-0.51-0.2-0.71,0s-0.2,0.51,0,0.71L2.29,3L0.53,4.77c-0.2,0.2-0.2,0.51,0,0.71
C0.72,5.67,1.04,5.67,1.23,5.47z"/>
</svg>

Before

(image error) Size: 646 B

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 6 6" style="enable-background:new 0 0 6 6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<line class="st0" x1="0.5" y1="1" x2="5.5" y2="1"/>
<path d="M5.5,1.5h-5C0.22,1.5,0,1.28,0,1s0.22-0.5,0.5-0.5h5C5.78,0.5,6,0.72,6,1S5.78,1.5,5.5,1.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="3" x2="5.5" y2="3"/>
<path d="M5.5,3.5h-5C0.22,3.5,0,3.28,0,3s0.22-0.5,0.5-0.5h5C5.78,2.5,6,2.72,6,3S5.78,3.5,5.5,3.5z"/>
</g>
<g>
<line class="st0" x1="0.5" y1="5" x2="5.5" y2="5"/>
<path d="M5.5,5.5h-5C0.22,5.5,0,5.28,0,5s0.22-0.5,0.5-0.5h5C5.78,4.5,6,4.72,6,5S5.78,5.5,5.5,5.5z"/>
</g>
</svg>

Before

(image error) Size: 914 B

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 13 13" style="enable-background:new 0 0 13 13;" xml:space="preserve">
<path d="M9.5,6H7V3.5C7,3.22,6.78,3,6.5,3S6,3.22,6,3.5V6H3.5C3.22,6,3,6.22,3,6.5S3.22,7,3.5,7H6v2.5C6,9.78,6.22,10,6.5,10
S7,9.78,7,9.5V7h2.5C9.78,7,10,6.78,10,6.5S9.78,6,9.5,6z"/>
</svg>

After

(image error) Size: 541 B

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 15 13" style="enable-background:new 0 0 15 13;" xml:space="preserve">
<g>
<path d="M7.85,7.08C7.81,7.04,7.75,7,7.69,6.98c-0.12-0.05-0.26-0.05-0.38,0C7.25,7,7.19,7.04,7.15,7.08l-2.5,2.5
c-0.2,0.2-0.2,0.51,0,0.71s0.51,0.2,0.71,0L7,8.64v3.79c0,0.28,0.22,0.5,0.5,0.5S8,12.71,8,12.44V8.64l1.65,1.65
c0.1,0.1,0.23,0.15,0.35,0.15s0.26-0.05,0.35-0.15c0.2-0.2,0.2-0.51,0-0.71L7.85,7.08z"/>
<path d="M9.38,0.06c-2.6,0-4.83,1.85-5.36,4.38H3.44c-1.83,0-3.31,1.49-3.31,3.31c0,1.01,0.45,1.95,1.23,2.58
c0.27,0.22,0.57,0.39,0.89,0.51c0.06,0.02,0.12,0.03,0.18,0.03c0.2,0,0.39-0.12,0.47-0.32c0.1-0.26-0.03-0.55-0.29-0.65
C2.38,9.82,2.17,9.7,1.99,9.55c-0.55-0.44-0.86-1.1-0.86-1.8c0-1.27,1.04-2.31,2.31-2.31h1c0.25,0,0.46-0.19,0.5-0.44
c0.28-2.24,2.19-3.94,4.44-3.94c2.48,0,4.5,2.02,4.5,4.5c0,1.29-0.56,2.53-1.53,3.38c-0.21,0.18-0.23,0.5-0.05,0.71
c0.18,0.21,0.5,0.23,0.71,0.05c1.19-1.04,1.87-2.55,1.87-4.13C14.88,2.53,12.41,0.06,9.38,0.06z"/>
</g>
</svg>

Before

(image error) Size: 1.2 KiB

@ -1,51 +0,0 @@
import { BASE_URL } from './constants';
import { ApiResponse } from './types';
import { unpack } from './utils';
const send = async <T>(
url: string,
init: RequestInit,
): Promise<ApiResponse<T>> => {
const fullURL = `${BASE_URL}/${url}`;
const fullInit: RequestInit = { ...init };
try {
const response = await fetch(fullURL, fullInit);
if (!response.ok) {
return {
data: null,
error: { status: response.status, message: 'Something went wrong' },
};
}
const raw = await response.json();
const data: T = unpack(raw);
return { data: data, error: null };
} catch {
return {
data: null,
error: { status: 0, message: 'Something went wrong' },
};
}
};
export const api = {
get: async <T>(url: string) => {
return send<T>(url, { method: 'GET' });
},
post: async <T>(url: string, body: unknown) => {
return send<T>(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
put: async <T>(url: string, body: unknown) => {
return send<T>(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
},
delete: async <T>(url: string) => {
return send<T>(url, { method: 'DELETE' });
},
};

@ -1 +0,0 @@
export const BASE_URL = 'http://localhost:8000';

@ -0,0 +1,2 @@
// export const BASE_URL = 'http://localhost:8000/api';
export const BASE_URL = 'http://192.168.1.110:8000/api';

@ -0,0 +1 @@
export { downloadImage, getWindmillData } from './service';

@ -0,0 +1,26 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
import { BASE_URL } from './constants';
import { GetWindmillDataRes } from './types';
import { getWindmillDataParams } from './utils';
export const getWindmillData = async (store: Partial<WindmillFormStore>) => {
const params = getWindmillDataParams(store);
const url = `${BASE_URL}/floris/get_windmill_data?${params}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data: GetWindmillDataRes = await res.json();
return data;
};
export const downloadImage = async (imageName: string) => {
const url = `${BASE_URL}/floris/download_image/${imageName}`;
const init: RequestInit = {
method: 'GET',
};
const res: Response = await fetch(url, init);
const data = await res.blob();
return data;
};

@ -0,0 +1,4 @@
export type GetWindmillDataRes = {
file_name: string;
data: number[];
};

@ -0,0 +1,9 @@
import { WindmillFormStore } from '@components/ux/windmill-form';
export const getWindmillDataParams = (store: Partial<WindmillFormStore>) => {
const layoutX = store.windmills?.map((row) => `layout_x=${row.x}`).join('&');
const layoutY = store.windmills?.map((row) => `layout_y=${row.y}`).join('&');
const dateStart = `date_start=${store.dateFrom?.substring(0, 10)}`;
const dateEnd = `date_end=${store.dateTo?.substring(0, 10)}`;
return `${layoutX}&${layoutY}&${dateStart}&${dateEnd}`;
};

1
front/src/api/index.tsx Normal file

@ -0,0 +1 @@
export * from './floris';

@ -1,9 +0,0 @@
export type ApiError = {
status: number;
message: string;
};
export type ApiResponse<T> = {
data: T | null;
error: ApiError | null;
};

@ -1,23 +0,0 @@
export const toCamelCase = (str: string) => {
return str
.split(/[_\s-]+|(?=[A-Z])/)
.map((word, index) =>
index === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join('');
};
export const unpack = (obj: unknown) => {
if (Array.isArray(obj)) {
return obj.map((item) => unpack(item));
} else if (obj !== null && typeof obj === 'object') {
return Object.entries(obj).reduce((acc, [key, value]) => {
const newKey = toCamelCase(key);
acc[newKey] = unpack(value);
return acc;
}, {});
}
return obj;
};

@ -1,7 +0,0 @@
export const WIND_ENDPOINTS = {
turbines: 'api/wind/turbines',
turbineType: 'api/wind/turbine_type',
parks: 'api/wind/parks',
park: 'api/wind/park',
parkTurbine: 'api/wind/park_turbine',
};

@ -1,2 +0,0 @@
export * from './service';
export * from './types';

@ -1,117 +0,0 @@
import { ApiResponse } from '@api/types';
import { ParkFormValues } from '@components/pages/park-page/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
import { api } from '../api';
import { WIND_ENDPOINTS } from './constants';
import { Park, ParkTurbine, ParkWithTurbines, TurbineType } from './types';
import { packPark, packParkTurbine, packTurbineTypes } from './utils';
export const getTurbineTypes = () => {
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
};
export const getTurbineType = (id: string) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.get<TurbineType>(url);
};
export const createTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
) => {
return api.post<TurbineType>(
WIND_ENDPOINTS.turbineType,
packTurbineTypes(formValues),
);
};
export const editTurbineTypes = (
formValues: Partial<TurbineTypeFormValues>,
id: string,
) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.put<TurbineType>(url, packTurbineTypes(formValues));
};
export const deleteTurbineType = (id: number) => {
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
return api.delete(url);
};
export const getParks = () => {
return api.get<Park[]>(WIND_ENDPOINTS.parks);
};
export const getPark = (id: string) => {
const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.get<Park>(url);
};
export const getParkTurbines = (id: string) => {
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
return api.get<ParkTurbine[]>(url);
};
export const getParkWithTurbines = async (
id: string,
): Promise<ApiResponse<ParkWithTurbines>> => {
const parkURL = `${WIND_ENDPOINTS.park}/${id}`;
const turbinesURL = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
const parkPesponse = await api.get<Park>(parkURL);
const turbinesResponse = await api.get<ParkTurbine[]>(turbinesURL);
return {
data: { ...parkPesponse.data, turbines: turbinesResponse.data },
error: parkPesponse.error || turbinesResponse.error || null,
};
};
export const createPark = async (formValues: Partial<ParkFormValues>) => {
const parkPesponse = await api.post<Park>(
WIND_ENDPOINTS.park,
packPark(formValues),
);
await Promise.all(
formValues.turbines?.map((t) => {
return api.post(
WIND_ENDPOINTS.parkTurbine,
packParkTurbine(t, parkPesponse.data.id),
);
}),
);
return getParkWithTurbines(String(parkPesponse.data.id));
};
export const updatePark = async (
formValues: Partial<ParkFormValues>,
id: string,
) => {
const parkPesponse = await api.put<Park>(
`${WIND_ENDPOINTS.park}/${id}`,
packPark(formValues),
);
await Promise.all(
formValues.turbines?.map((t) => {
if (t.new) {
return api.post(
WIND_ENDPOINTS.parkTurbine,
packParkTurbine(t, parkPesponse.data.id),
);
}
if (t.delete) {
return api.delete(
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
);
}
return api.put(
`${WIND_ENDPOINTS.parkTurbine}/${parkPesponse.data.id}/${t.id}`,
packParkTurbine(t, parkPesponse.data.id),
);
}),
);
return getParkWithTurbines(id);
};
export const deletePark = (id: number) => {
const url = `${WIND_ENDPOINTS.park}/${id}`;
return api.delete(url);
};

@ -1,32 +0,0 @@
export type TurbineType = {
id: number;
name: string;
height: number;
bladeLength: number;
};
export type Park = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
};
export type ParkTurbine = {
id: number;
name: string;
height: number;
bladeLength: number;
xOffset: number;
yOffset: number;
angle: number;
comment: string;
};
export type ParkWithTurbines = {
id: number;
name: string;
centerLatitude: number;
centerLongitude: number;
turbines: ParkTurbine[];
};

@ -1,32 +0,0 @@
import {
ParkFormTurbine,
ParkFormValues,
} from '@components/pages/park-page/types';
import { TurbineTypeFormValues } from '@components/pages/turbine-type-page/types';
export const packTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
return {
Name: values.name ?? '',
Height: parseInt(values.height || '0'),
BladeLength: parseInt(values.bladeLength || '0'),
};
};
export const packPark = (values: Partial<ParkFormValues>) => {
return {
Name: values.name ?? '',
CenterLatitude: parseInt(values.centerLatitude || '0'),
CenterLongitude: parseInt(values.centerLongitude || '0'),
};
};
export const packParkTurbine = (turbine: ParkFormTurbine, parkId: number) => {
return {
wind_park_id: parkId,
turbine_id: turbine.id,
x_offset: parseInt(turbine.xOffset || '0'),
y_offset: parseInt(turbine.yOffset || '0'),
angle: parseInt(turbine.angle || '0'),
comment: turbine.comment ?? '',
};
};

@ -1,19 +0,0 @@
@function scale($values, $factor) {
@if type-of($values) == 'list' {
$m-values: ();
@each $value in $values {
$m-values: append($m-values, $value * $factor);
}
@return $m-values;
} @else {
@return nth($values, 1) * $factor;
}
}
@function m($values) {
@return scale($values, 1.25);
}
@function l($values) {
@return scale($values, 1.5);
}

@ -1,5 +0,0 @@
@mixin on-mobile {
@media (width <= 800px) {
@content;
}
}

@ -2,12 +2,14 @@
color-scheme: light; color-scheme: light;
--clr-primary: #4176FF; --clr-primary: #4176FF;
--clr-primary-o50: #4176FF80; --clr-primary-o50: #3865DA80;
--clr-primary-hover: #638FFF; --clr-primary-hover: #638FFF;
--clr-primary-active: #3D68D7;
--clr-on-primary: #FFFFFF; --clr-on-primary: #FFFFFF;
--clr-secondary: #E1EAF8; --clr-secondary: #EAEAEA;
--clr-secondary-hover: #E8ECF0; --clr-secondary-hover: #EFEFEF;
--clr-secondary-active: #E1E1E1;
--clr-on-secondary: #0D0D0D; --clr-on-secondary: #0D0D0D;
--clr-layer-100: #EBEEF0; --clr-layer-100: #EBEEF0;
@ -18,7 +20,6 @@
--clr-text-100: #8D8D8D; --clr-text-100: #8D8D8D;
--clr-text-200: #6C7480; --clr-text-200: #6C7480;
--clr-text-300: #1D1F20; --clr-text-300: #1D1F20;
--clr-text-primary: #3865DA;
--clr-border-100: #DFDFDF; --clr-border-100: #DFDFDF;
--clr-border-200: #D8D8D8; --clr-border-200: #D8D8D8;
@ -27,8 +28,6 @@
--clr-shadow-200: #00000026; --clr-shadow-200: #00000026;
--clr-ripple: #1D1F2026; --clr-ripple: #1D1F2026;
--clr-error: #E54B4B;
} }
@mixin dark { @mixin dark {
@ -37,12 +36,12 @@
--clr-primary: #3865DA; --clr-primary: #3865DA;
--clr-primary-o50: #3865DA80; --clr-primary-o50: #3865DA80;
--clr-primary-hover: #4073F7; --clr-primary-hover: #4073F7;
--clr-primary-disabled: #334570; --clr-primary-active: #2A4DA7;
--clr-on-primary: #FFFFFF; --clr-on-primary: #FFFFFF;
--clr-secondary: #3F3F3F; --clr-secondary: #3F3F3F;
--clr-secondary-hover: #4D4D4D; --clr-secondary-hover: #4D4D4D;
--clr-secondary-disabled: #323232; --clr-secondary-active: #323232;
--clr-on-secondary: #FFFFFF; --clr-on-secondary: #FFFFFF;
--clr-layer-100: #1B1B1B; --clr-layer-100: #1B1B1B;
@ -53,7 +52,6 @@
--clr-text-100: #888888; --clr-text-100: #888888;
--clr-text-200: #C5C5C5; --clr-text-200: #C5C5C5;
--clr-text-300: #F0F0F0; --clr-text-300: #F0F0F0;
--clr-text-primary: #4176FF;
--clr-border-100: #3D3D3D; --clr-border-100: #3D3D3D;
--clr-border-200: #545454; --clr-border-200: #545454;
@ -62,6 +60,4 @@
--clr-shadow-200: #00000026; --clr-shadow-200: #00000026;
--clr-ripple: #F0F0F026; --clr-ripple: #F0F0F026;
--clr-error: #FF6363;
} }

@ -1,35 +0,0 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import {
ParkPage,
ParksPage,
TurbineTypePage,
TurbineTypesPage,
} from '@components/pages';
import { ROUTES } from '@utils/route';
import React from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route
path={ROUTES.turbineTypes.path}
element={<TurbineTypesPage />}
/>
<Route path={ROUTES.turbineType.path} element={<TurbineTypePage />} />
<Route path={ROUTES.parks.path} element={<ParksPage />} />
<Route path={ROUTES.park.path} element={<ParkPage />} />
</Route>
<Route
path="*"
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
/>
</Routes>
</BrowserRouter>
);
}

@ -1 +0,0 @@
export { App } from './component';

@ -0,0 +1,21 @@
import './styles.scss';
import '@public/fonts/styles.css';
import { MainLayout } from '@components/layouts';
import { HomePage } from '@components/pages';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<MainLayout />}>
<Route path={'/'} element={<HomePage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

@ -23,7 +23,7 @@ html[data-theme='default'] {
} }
html { html {
--td-100: 0.1s; --td-100: 0.2s;
} }
body { body {

@ -1 +0,0 @@
export { MainLayout } from './main-layout';

@ -0,0 +1,3 @@
import MainLayout from './main-layout';
export { MainLayout };

@ -1,22 +0,0 @@
import { Header, Sidebar } from '@components/ux';
import { useDeviceType } from '@utils/device';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
export function MainLayout() {
const deviceType = useDeviceType();
return (
<div className={styles.mainLayout}>
{deviceType === 'mobile' ? (
<Header className={styles.header} />
) : (
<Sidebar className={styles.sidebar} />
)}
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}

@ -1 +1,18 @@
export * from './component'; import { Header } from '@components/ux';
import React from 'react';
import { Outlet } from 'react-router-dom';
import styles from './styles.module.scss';
function MainLayout() {
return (
<div className={styles.mainLayout}>
<Header />
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}
export default MainLayout;

@ -1,38 +1,13 @@
@use '@components/mixins.scss' as m;
.mainLayout { .mainLayout {
display: grid; display: grid;
height: 100%; height: 100%;
grid-template: grid-template:
'sidebar main' minmax(0, 1fr) 'header' auto
/ auto minmax(0, 1fr); 'main' minmax(0, 1fr)
} / minmax(0, 1fr);
.sidebar {
grid-area: sidebar;
}
.header {
grid-area: header;
} }
.main { .main {
display: grid;
overflow: auto; overflow: auto;
height: 100%; height: 100%;
grid-area: main;
grid-template-columns: 1fr minmax(0, 1000px) 1fr;
& > * {
grid-column: 2;
}
}
@include m.on-mobile {
.mainLayout {
grid-template:
'header' auto
'main' minmax(0, 1fr)
/ minmax(0, 1fr);
}
} }

@ -0,0 +1,41 @@
import { Heading, Paragraph } from '@components/ui';
import { WindmillForm } from '@components/ux';
import { WindmillFormResponse } from '@components/ux/windmill-form';
import React, { useState } from 'react';
import styles from './styles.module.scss';
export function HomePage() {
const [result, setResult] = useState<WindmillFormResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFormSuccess = (response: WindmillFormResponse) => {
setResult(response);
setError(null);
};
const handleFormFail = (message: string) => {
setError(message);
setResult(null);
};
return (
<div className={styles.page}>
<div className={styles.wrapperForm}>
<WindmillForm onSuccess={handleFormSuccess} onFail={handleFormFail} />
</div>
<div className={styles.result}>
<Heading tag="h3">Result</Heading>
{result && (
<>
<div className={styles.power}>{result.power.join(' ')}</div>
<div className={styles.image}>
{result.image && <img src={result.image} alt="Image" />}
</div>
</>
)}
{error && <Paragraph>{error}</Paragraph>}
</div>
</div>
);
}

@ -0,0 +1 @@
export { HomePage } from './component';

@ -0,0 +1,41 @@
.page {
display: grid;
padding: 20px;
gap: 20px;
grid-template:
'. form result .' auto
/ auto minmax(0, 380px) minmax(0, 700px) auto;
}
.wrapperForm {
grid-area: form;
}
.result {
display: flex;
flex-direction: column;
padding: 20px;
border-radius: 10px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
grid-area: result;
}
.image {
width: 100%;
img {
max-width: 100%;
border-radius: 10px;
}
}
@media (width <= 1000px) {
.page {
grid-template:
'form' auto
'result' auto
/ 1fr;
}
}

@ -1,4 +0,0 @@
export * from './park-page';
export * from './parks-page';
export * from './turbine-type-page';
export * from './turbine-types-page';

@ -0,0 +1 @@
export { HomePage } from './home-page';

@ -1,128 +0,0 @@
import {
createPark,
getParkWithTurbines,
ParkWithTurbines,
updatePark,
} from '@api/wind';
import {
Button,
Dialog,
Heading,
NumberField,
TextInput,
} from '@components/ui';
import { ParkTurbines } from '@components/ux';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { ParkFormValues } from './types';
import { unpackPark } from './utils';
export function ParkPage() {
const [park, setPark] = useState<ParkWithTurbines>(null);
const [pending, setPending] = useState<boolean>(false);
const [error, setError] = useState<string>(null);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } = useForm<ParkFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchPark = async () => {
const response = await getParkWithTurbines(id);
setPark(response.data);
reset(unpackPark(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchPark();
}, [id]);
const handleFormSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const { data, error } = await updatePark(getValues(), id);
if (error) {
setError('Something went wrong');
return;
}
setPark(data);
reset(unpackPark(data));
} else {
const { data, error } = await createPark(getValues());
if (error) {
setError('Something went wrong');
return;
}
navigate(ROUTES.park.path.replace(':id', String(data.id)));
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackPark(park));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleFormSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('centerLatitude')}
render={(props) => (
<NumberField label={{ text: 'Center Latitude' }} {...props} />
)}
/>
<Controller
{...control('centerLongitude')}
render={(props) => (
<NumberField label={{ text: 'Center Longitude' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
<Controller
{...control('turbines')}
render={(props) => (
<ParkTurbines savedTurbines={park?.turbines ?? []} {...props} />
)}
/>
<Dialog
open={Boolean(error)}
heading="Error"
message="Something went wrong"
onClose={() => setError(null)}
>
<Button onClick={() => setError(null)}>Ok</Button>
</Dialog>
</div>
);
}

@ -1 +0,0 @@
export * from './component';

@ -1,28 +0,0 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto 1fr;
}
.form {
display: grid;
padding: 20px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 20px;
}
.inputBox {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.buttonBox {
display: flex;
justify-content: end;
padding-top: 20px;
gap: 10px;
}

@ -1,17 +0,0 @@
export type ParkFormTurbine = {
id: number;
name: string;
xOffset: string;
yOffset: string;
angle: string;
comment: string;
new?: boolean;
delete?: boolean;
};
export type ParkFormValues = {
name: string;
centerLatitude: string;
centerLongitude: string;
turbines: ParkFormTurbine[];
};

@ -1,19 +0,0 @@
import { ParkWithTurbines } from '@api/wind';
import { ParkFormValues } from './types';
export const unpackPark = (park: ParkWithTurbines): ParkFormValues => {
return {
name: park.name,
centerLatitude: String(park.centerLatitude),
centerLongitude: String(park.centerLongitude),
turbines: park.turbines.map((t) => ({
id: t.id,
name: t.name,
xOffset: String(t.xOffset),
yOffset: String(t.yOffset),
angle: String(t.angle),
comment: t.comment,
})),
};
};

@ -1,64 +0,0 @@
import { deletePark, getParks, Park } from '@api/wind';
import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { columns } from './constants';
import styles from './styles.module.scss';
export function ParksPage() {
const [parks, setParks] = useState<Park[]>([]);
const [selected, setSelected] = useState<Park>(null);
const route = useRoute();
const fetchParks = async () => {
const res = await getParks();
setParks(res.data);
};
useEffect(() => {
fetchParks();
}, []);
const handleParkSelect = (items: Park[]) => {
setSelected(items[0] ?? null);
};
const handleDeleteButtonClick = async () => {
await deletePark(selected.id);
fetchParks();
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<div className={styles.actions}>
<Link to={ROUTES.park.path.replace(':id', 'new')}>
<Button>Create new</Button>
</Link>
{selected && (
<Link to={ROUTES.park.path.replace(':id', String(selected.id))}>
<Button variant="secondary">Edit</Button>
</Link>
)}
{selected && (
<Button variant="secondary" onClick={handleDeleteButtonClick}>
Delete
</Button>
)}
</div>
<div className={styles.dataGridWrapper}>
<DataGrid
items={parks}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={selected ? [selected] : []}
onItemsSelect={handleParkSelect}
multiselect={false}
/>
</div>
</div>
);
}

@ -1,8 +0,0 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { Park } from 'src/api/wind';
export const columns: DataGridColumnConfig<Park>[] = [
{ name: 'Name', getText: (t) => t.name, width: 2 },
{ name: 'Center Latitude', getText: (t) => String(t.centerLatitude) },
{ name: 'Center Longitude', getText: (t) => String(t.centerLongitude) },
];

@ -1 +0,0 @@
export * from './component';

@ -1,19 +0,0 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.dataGridWrapper {
overflow: auto;
}
.actions {
display: flex;
padding: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
}

@ -1,101 +0,0 @@
import {
createTurbineTypes,
editTurbineTypes,
getTurbineType,
TurbineType,
} from '@api/wind';
import { Button, Heading, NumberField, TextInput } from '@components/ui';
import { Controller, useForm } from '@utils/form';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styles from './styles.module.scss';
import { TurbineTypeFormValues } from './types';
import { unpackTurbineType } from './utils';
export function TurbineTypePage() {
const [turbineType, setTurbineType] = useState<TurbineType>(null);
const [pending, setPending] = useState<boolean>(false);
const params = useParams();
const navigate = useNavigate();
const route = useRoute();
const { register, control, getValues, reset } =
useForm<TurbineTypeFormValues>({});
const { id } = params;
const isEdit = id !== 'new';
const heading = isEdit ? 'Edit' : 'Create new';
const fetchTurbineType = async () => {
const response = await getTurbineType(id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
};
useEffect(() => {
if (!isEdit) {
return;
}
fetchTurbineType();
}, [id]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
if (isEdit) {
const response = await editTurbineTypes(getValues(), id);
setTurbineType(response.data);
reset(unpackTurbineType(response.data));
} else {
const response = await createTurbineTypes(getValues());
navigate(
ROUTES.turbineType.path.replace(':id', String(response.data.id)),
);
}
setPending(false);
};
const handleReset = () => {
if (isEdit) {
reset(unpackTurbineType(turbineType));
} else {
reset({});
}
};
return (
<div className={styles.page} onSubmit={handleSubmit}>
<Heading tag="h1">{route.title}</Heading>
<form className={styles.form}>
<header>
<Heading tag="h3">{heading}</Heading>
</header>
<TextInput {...register('name')} label={{ text: 'Name' }} />
<div className={styles.inputBox}>
<Controller
{...control('height')}
render={(props) => (
<NumberField label={{ text: 'Height' }} {...props} />
)}
/>
<Controller
{...control('bladeLength')}
render={(props) => (
<NumberField label={{ text: 'Blade length' }} {...props} />
)}
/>
</div>
<div className={styles.buttonBox}>
<Button variant="secondary" onClick={handleReset}>
Reset
</Button>
<Button type="submit" pending={pending}>
Submit
</Button>
</div>
</form>
</div>
);
}

@ -1 +0,0 @@
export * from './component';

@ -1,5 +0,0 @@
export type TurbineTypeFormValues = {
name: string;
height: string;
bladeLength: string;
};

@ -1,13 +0,0 @@
import { TurbineType } from '@api/wind';
import { TurbineTypeFormValues } from './types';
export const unpackTurbineType = (
turbineType: TurbineType,
): TurbineTypeFormValues => {
return {
name: turbineType.name,
height: String(turbineType.height),
bladeLength: String(turbineType.bladeLength),
};
};

@ -1,66 +0,0 @@
import { deleteTurbineType, getTurbineTypes, TurbineType } from '@api/wind';
import { Button, Heading } from '@components/ui';
import { DataGrid } from '@components/ui/data-grid';
import { ROUTES, useRoute } from '@utils/route';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { columns } from './constants';
import styles from './styles.module.scss';
export function TurbineTypesPage() {
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
const [selected, setSelected] = useState<TurbineType>(null);
const route = useRoute();
const fetchTurbineTypes = async () => {
const res = await getTurbineTypes();
setTurbineTypes(res.data ?? []);
};
useEffect(() => {
fetchTurbineTypes();
}, []);
const handleTurbineTypeSelect = (items: TurbineType[]) => {
setSelected(items[0] ?? null);
};
const handleDeleteButtonClick = async () => {
await deleteTurbineType(selected.id);
fetchTurbineTypes();
};
return (
<div className={styles.page}>
<Heading tag="h1">{route.title}</Heading>
<div className={styles.actions}>
<Link to={ROUTES.turbineType.path.replace(':id', 'new')}>
<Button>Create new</Button>
</Link>
{selected && (
<Link
to={ROUTES.turbineType.path.replace(':id', String(selected.id))}
>
<Button variant="secondary">Edit</Button>
</Link>
)}
{selected && (
<Button variant="secondary" onClick={handleDeleteButtonClick}>
Delete
</Button>
)}
</div>
<div className={styles.dataGridWrapper}>
<DataGrid
items={turbineTypes}
columns={columns}
getItemKey={({ id }) => String(id)}
selectedItems={selected ? [selected] : []}
onItemsSelect={handleTurbineTypeSelect}
multiselect={false}
/>
</div>
</div>
);
}

@ -1,8 +0,0 @@
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
import { TurbineType } from 'src/api/wind';
export const columns: DataGridColumnConfig<TurbineType>[] = [
{ name: 'Name', getText: (t) => t.name, width: 2 },
{ name: 'Height', getText: (t) => String(t.height) },
{ name: 'Blade length', getText: (t) => String(t.bladeLength) },
];

@ -1 +0,0 @@
export * from './component';

@ -1,19 +0,0 @@
.page {
display: grid;
padding: 40px 20px;
gap: 20px;
grid-template-rows: auto auto minmax(0, 1fr);
}
.dataGridWrapper {
overflow: auto;
}
.actions {
display: flex;
padding: 10px;
border-radius: 15px;
background-color: var(--clr-layer-200);
box-shadow: 0px 1px 2px var(--clr-shadow-100);
gap: 10px;
}

@ -1,11 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
CSSProperties,
ForwardedRef,
forwardRef,
useEffect,
useState,
} from 'react';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { FadeProps } from './types'; import { FadeProps } from './types';
@ -20,7 +14,7 @@ export function FadeInner(
}: Omit<FadeProps, 'ref'>, }: Omit<FadeProps, 'ref'>,
ref: ForwardedRef<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>,
) { ) {
const [visibleInternal, setVisibleInternal] = useState<boolean>(visible); const [visibleInner, setVisibleInner] = useState<boolean>(visible);
const classNames = clsx( const classNames = clsx(
styles.fade, styles.fade,
@ -31,21 +25,22 @@ export function FadeInner(
const inlineStyle = { const inlineStyle = {
...style, ...style,
'--animation-duration': `${duration}ms`, '--animation-duration': `${duration}ms`,
} as CSSProperties; } as React.CSSProperties;
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setVisibleInternal(true); setVisibleInner(true);
return;
} }
}, [visible]); }, [visible]);
const handleAnimationEnd = (event: React.AnimationEvent) => { const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadeout) { if (event.animationName === styles.fadeout) {
setVisibleInternal(false); setVisibleInner(false);
} }
}; };
if (!visibleInternal) { if (!visibleInner) {
return null; return null;
} }

@ -1,5 +1,5 @@
.fade { .fade {
animation: fadein var(--animation-duration) ease-in-out; animation: fadein var(--animation-duration);
} }
.invisible { .invisible {

@ -1,6 +1,4 @@
import { ComponentProps } from 'react';
export type FadeProps = { export type FadeProps = {
visible: boolean; visible: boolean;
duration?: number; duration?: number;
} & ComponentProps<'div'>; } & React.ComponentProps<'div'>;

@ -1,65 +1,68 @@
import clsx from 'clsx'; import React, {
import React, { useRef } from 'react'; ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { RippleWave } from './parts/ripple-wave';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { RippleProps } from './types';
import { calcRippleWaveStyle } from './utils'; import { calcRippleWaveStyle } from './utils';
export function Ripple() { export function RippleInner(
props: RippleProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const rippleRef = useRef<HTMLDivElement | null>(null); const rippleRef = useRef<HTMLDivElement | null>(null);
const [waves, setWaves] = useState<React.JSX.Element[]>([]);
const [isTouch, setIsTouch] = useState(false);
const clean = () => { useImperativeHandle(ref, () => rippleRef.current, []);
document.removeEventListener('touchend', clean);
document.removeEventListener('mouseup', clean);
if (!rippleRef.current) {
return;
}
const { lastChild: wave } = rippleRef.current;
if (!wave || !(wave instanceof HTMLElement)) {
return;
}
wave.dataset.isMouseReleased = 'true';
if (wave.dataset.isAnimationComplete) {
wave.classList.replace(styles.visible, styles.invisible);
}
};
const handleAnimationEnd = (event: AnimationEvent) => { const handleWaveOnDone = () => {
const { target: wave, animationName } = event; setWaves((prev) => prev.slice(1));
if (!(wave instanceof HTMLElement)) {
return;
}
if (animationName === styles.fadein) {
wave.dataset.isAnimationComplete = 'true';
if (wave.dataset.isMouseReleased) {
wave.classList.replace(styles.visible, styles.invisible);
}
} else {
wave.remove();
}
}; };
const addWave = (x: number, y: number) => { const addWave = (x: number, y: number) => {
const wave = document.createElement('div');
const style = calcRippleWaveStyle(x, y, rippleRef.current); const style = calcRippleWaveStyle(x, y, rippleRef.current);
Object.assign(wave.style, style); const wave = (
wave.className = clsx(styles.wave, styles.visible); <RippleWave
wave.addEventListener('animationend', handleAnimationEnd); key={new Date().getTime()}
rippleRef.current.appendChild(wave); style={style}
document.addEventListener('touchend', clean); onDone={handleWaveOnDone}
document.addEventListener('mouseup', clean); />
);
setWaves([...waves, wave]);
}; };
const handlePointerDown = (event: React.MouseEvent) => { const handleMouseDown = (event: React.MouseEvent) => {
event.stopPropagation(); if (isTouch) {
return;
}
const { pageX, pageY } = event; const { pageX, pageY } = event;
addWave(pageX, pageY); addWave(pageX, pageY);
}; };
const handleTouchStart = (event: React.TouchEvent) => {
setIsTouch(true);
const { touches, changedTouches } = event;
const { pageX, pageY } = touches[0] ?? changedTouches[0];
addWave(pageX, pageY);
};
return ( return (
<div <div
className={styles.ripple}
ref={rippleRef} ref={rippleRef}
onPointerDown={handlePointerDown} className={styles.ripple}
/> onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
{...props}
>
{waves}
</div>
); );
} }
export const Ripple = forwardRef(RippleInner);

@ -0,0 +1,39 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import styles from './style.module.scss';
import { RippleWaveProps } from './types';
export function RippleWave({ style, onDone }: RippleWaveProps) {
const [isMouseUp, setIsMouseUp] = useState(false);
const [isAnimationEnd, setIsAnimationEnd] = useState(false);
useEffect(() => {
const mouseUpListener = () => setIsMouseUp(true);
document.addEventListener('mouseup', mouseUpListener, { once: true });
document.addEventListener('touchend', mouseUpListener, { once: true });
}, []);
const visible = !isMouseUp || !isAnimationEnd;
const className = clsx(
styles.wave,
visible ? styles.visible : styles.invisible,
);
const handleAnimationEnd = (event: React.AnimationEvent) => {
if (event.animationName === styles.fadein) {
setIsAnimationEnd(true);
} else {
onDone();
}
};
return (
<div
className={className}
onAnimationEnd={handleAnimationEnd}
style={style}
/>
);
}

@ -0,0 +1 @@
export { RippleWave } from './component';

@ -0,0 +1,33 @@
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
}
.visible {
animation: fadein 0.3s linear;
}
.invisible {
animation: fadeout 0.3s linear forwards;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

@ -0,0 +1,6 @@
import { CSSProperties } from 'react';
export type RippleWaveProps = {
style: CSSProperties;
onDone: () => void;
};

@ -5,38 +5,3 @@
width: 200%; width: 200%;
height: 200%; height: 200%;
} }
.wave {
position: absolute;
border-radius: 100%;
background-color: var(--clr-ripple);
pointer-events: none;
}
.visible {
animation: fadein 0.25s linear;
}
.invisible {
animation: fadeout 0.25s linear;
}
@keyframes fadein {
from {
opacity: 0;
scale: 0;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

@ -0,0 +1 @@
export type RippleProps = {} & React.ComponentProps<'div'>;

@ -1,4 +1,3 @@
import { px } from '@utils/css';
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
export const calcRippleWaveStyle = ( export const calcRippleWaveStyle = (
@ -9,7 +8,7 @@ export const calcRippleWaveStyle = (
const wrapperRect = ripple.getBoundingClientRect(); const wrapperRect = ripple.getBoundingClientRect();
const diameter = Math.max(wrapperRect.width, wrapperRect.height); const diameter = Math.max(wrapperRect.width, wrapperRect.height);
const radius = diameter / 2; const radius = diameter / 2;
const left = px(x - wrapperRect.left - radius); const left = x - wrapperRect.left - radius;
const top = px(y - wrapperRect.top - radius); const top = y - wrapperRect.top - radius;
return { left, top, width: px(diameter), height: px(diameter) }; return { left, top, width: diameter, height: diameter };
}; };

@ -1,119 +0,0 @@
import ArrowDownIcon from '@public/images/svg/arrow-down.svg';
import { useMissClick } from '@utils/miss-click';
import clsx from 'clsx';
import React, {
ForwardedRef,
forwardRef,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { Menu } from '../menu';
import { Popover } from '../popover';
import { TextInput } from '../text-input';
import styles from './styles.module.scss';
import { AutocompleteProps } from './types';
function AutocompleteInner<T>(
{
options,
value,
getOptionKey,
getOptionLabel,
onChange,
scale = 'm',
label = {},
name,
id,
}: Omit<AutocompleteProps<T>, 'ref'>,
ref: ForwardedRef<HTMLDivElement>,
) {
const autocompleteRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLUListElement | null>(null);
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
const [menuVisible, setMenuVisible] = useState<boolean>(false);
const [text, setText] = useState<string>('');
useImperativeHandle(ref, () => autocompleteRef.current, []);
useMissClick({
callback: () => setMenuVisible(false),
enabled: menuVisible,
whitelist: [autocompleteRef, menuRef],
});
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
[styles.menuVisible]: menuVisible,
});
const filteredOptions = options.filter((option) => {
const label = getOptionLabel(option).toLocaleLowerCase();
const raw = text.trim().toLocaleLowerCase();
return label.includes(raw);
});
const handleInputClick = () => {
setMenuVisible(!menuVisible);
};
const handleMenuSelect = (option: T) => {
setMenuVisible(false);
onChange?.(option);
setText('');
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setText(value);
const option = options.find((option) => {
const label = getOptionLabel(option).toLocaleLowerCase();
const raw = value.toLocaleLowerCase();
return label === raw;
});
onChange?.(option ?? null);
};
return (
<div className={autocompleteClassName} ref={autocompleteRef}>
<TextInput
value={value ? getOptionLabel(value) : text}
onClick={handleInputClick}
scale={scale}
label={label}
name={name}
id={id}
wrapper={{ ref: inputWrapperRef }}
onChange={handleInputChange}
rightNode={
<div className={styles.iconBox}>
<ArrowDownIcon className={styles.icon} />
</div>
}
/>
<Popover
visible={menuVisible}
anchorRef={autocompleteRef}
position="bottom"
horizontalAlign="stretch"
flip
element={
<div className={styles.menuWrapper}>
<Menu
options={filteredOptions}
selected={value}
getOptionKey={getOptionKey}
getOptionLabel={getOptionLabel}
onSelect={handleMenuSelect}
ref={menuRef}
/>
</div>
}
/>
</div>
);
}
export const Autocomplete = forwardRef(AutocompleteInner) as <T>(
props: AutocompleteProps<T>,
) => ReturnType<typeof AutocompleteInner>;

@ -1,3 +0,0 @@
export { Autocomplete } from './component';
export { AutocompletePreview } from './preview';
export { type AutocompleteProps } from './types';

@ -1,44 +0,0 @@
import { PreviewArticle } from '@components/ui/preview';
import React, { useState } from 'react';
import { Autocomplete } from './component';
export function AutocompletePreview() {
const [selectValue, setSelectValue] = useState<string>();
const options = ['Orange', 'Banana', 'Apple', 'Avocado'];
return (
<PreviewArticle title="Autocomplete">
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="s"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="m"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
<Autocomplete
options={options}
getOptionKey={(o) => o}
getOptionLabel={(o) => o}
label={{ text: 'Select your favorite fruit' }}
scale="l"
value={selectValue}
onChange={(o) => setSelectValue(o)}
name="fruit"
/>
</PreviewArticle>
);
}

@ -1,62 +0,0 @@
@use '@components/func.scss' as f;
.autocomplete {
position: relative;
width: fit-content;
}
.icon {
fill: var(--clr-text-100);
transition: all var(--td-100) ease-in-out;
}
.fade {
position: absolute;
z-index: 1;
}
.menuVisible {
.icon {
rotate: 180deg;
}
}
.menuWrapper {
padding: 5px 0;
}
$padding-right: 7px;
$size: 10px;
.s {
.iconBox {
padding-right: $padding-right;
}
.icon {
width: $size;
height: $size;
}
}
.m {
.iconBox {
padding-right: f.m($padding-right);
}
.icon {
width: f.m($size);
height: f.m($size);
}
}
.l {
.iconBox {
padding-right: f.l($padding-right);
}
.icon {
width: f.l($size);
height: f.l($size);
}
}

@ -1,16 +0,0 @@
import { ComponentProps, Key } from 'react';
import { LabelProps } from '../label';
import { Scale } from '../types';
export type AutocompleteProps<T> = {
options: T[];
value?: T;
getOptionKey: (option: T) => Key;
getOptionLabel: (option: T) => string;
onChange?: (option: T) => void;
scale?: Scale;
label?: LabelProps;
name?: string;
id?: string;
} & Omit<ComponentProps<'div'>, 'onChange'>;

@ -1,25 +1,23 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { ForwardedRef, forwardRef } from 'react'; import React from 'react';
import { Ripple } from '../animation/ripple/component'; import { Ripple } from '../animation';
import { Comet } from '../comet'; import { Comet } from '../comet';
import { RawButton } from '../raw'; import { RawButton } from '../raw';
import { COMET_VARIANT_MAP } from './constants'; import { COMET_VARIANT_MAP } from './constants';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { ButtonProps } from './types.js'; import { ButtonProps } from './types.js';
function ButtonInner( export function Button({
{ variant = 'primary',
variant = 'primary', scale = 'm',
scale = 'm', pending = false,
pending = false, className,
className, children,
children, disabled,
...props ...props
}: ButtonProps, }: ButtonProps) {
ref: ForwardedRef<HTMLButtonElement>, const classNames = clsx(
) {
const buttonClassName = clsx(
styles.button, styles.button,
styles[variant], styles[variant],
styles[scale], styles[scale],
@ -27,7 +25,11 @@ function ButtonInner(
className, className,
); );
return ( return (
<RawButton className={buttonClassName} ref={ref} {...props}> <RawButton
className={classNames}
disabled={pending ? true : disabled}
{...props}
>
{pending && ( {pending && (
<div className={styles.cometWrapper}> <div className={styles.cometWrapper}>
<Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} /> <Comet scale={scale} variant={COMET_VARIANT_MAP[variant]} />
@ -38,5 +40,3 @@ function ButtonInner(
</RawButton> </RawButton>
); );
} }
export const Button = forwardRef(ButtonInner);

@ -1,11 +1,14 @@
@use '@components/func.scss' as f;
.button { .button {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
font-weight: 500; font-weight: 500;
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
&:disabled {
pointer-events: none;
}
&:not(:disabled) { &:not(:disabled) {
cursor: pointer; cursor: pointer;
} }
@ -23,8 +26,6 @@
} }
.pending { .pending {
pointer-events: none;
.childrenWrapper { .childrenWrapper {
visibility: hidden; visibility: hidden;
} }
@ -34,10 +35,12 @@
background-color: var(--clr-primary); background-color: var(--clr-primary);
color: var(--clr-on-primary); color: var(--clr-on-primary);
@media (hover: hover) { &:hover {
&:hover { background-color: var(--clr-primary-hover);
background-color: var(--clr-primary-hover); }
}
&.pending {
background-color: var(--clr-primary-active);
} }
} }
@ -45,31 +48,29 @@
background-color: var(--clr-secondary); background-color: var(--clr-secondary);
color: var(--clr-on-secondary); color: var(--clr-on-secondary);
@media (hover: hover) { &:hover {
&:hover { background-color: var(--clr-secondary-hover);
background-color: var(--clr-secondary-hover); }
}
&.pending {
background-color: var(--clr-secondary-active);
} }
} }
$padding: 10px 16px;
$border-radius: 8px;
$font-size: 12px;
.s { .s {
padding: $padding; padding: 10px 16px;
border-radius: $border-radius; border-radius: 8px;
font-size: $font-size; font-size: 12px;
} }
.m { .m {
padding: f.m($padding); padding: 14px 20px;
border-radius: f.m($border-radius); border-radius: 10px;
font-size: f.m($font-size); font-size: 16px;
} }
.l { .l {
padding: f.l($padding); padding: 18px 24px;
border-radius: f.l($border-radius); border-radius: 12px;
font-size: f.l($font-size); font-size: 20px;
} }

@ -1,6 +1,6 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react'; import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
import { CalendarDays } from './components'; import { CalendarDays } from './parts';
import { CalendarProps } from './types'; import { CalendarProps } from './types';
function CalendarInner( function CalendarInner(

@ -33,7 +33,7 @@ export function CalendarDays({
}, [date, min, max]); }, [date, min, max]);
const handleChange = (newValue: string) => { const handleChange = (newValue: string) => {
onChange(newValue); onChange?.(newValue);
}; };
return ( return (

@ -40,11 +40,18 @@
justify-content: center; justify-content: center;
border-radius: 10px; border-radius: 10px;
color: var(--clr-text-100); color: var(--clr-text-100);
cursor: pointer;
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
&:hover { &:not(:disabled) {
background-color: var(--clr-layer-300-hover); cursor: pointer;
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
&:disabled {
color: var(--clr-text-100);
} }
} }

@ -9,7 +9,7 @@ export type CalendarDay = {
export type CalendarDaysProps = { export type CalendarDaysProps = {
value?: string; value?: string;
onChange: (value: string) => void; onChange?: (value: string) => void;
min: Date | null; min: Date | null;
max: Date | null; max: Date | null;
date: Date; date: Date;

@ -1,18 +1,11 @@
import { dateToInputString } from '@utils/date';
import { CalendarDay, GetCalendarDaysParams } from './types'; import { CalendarDay, GetCalendarDaysParams } from './types';
const addDays = (date: Date, days: number) => { const addDays = (date: Date, days: number) => {
date.setDate(date.getDate() + days); date.setDate(date.getDate() + days);
}; };
function dateToInputString(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
const daysAreEqual = (date1: Date, date2: Date) => { const daysAreEqual = (date1: Date, date2: Date) => {
return ( return (
date1.getDate() === date2.getDate() && date1.getDate() === date2.getDate() &&

@ -1,8 +1,6 @@
import { ComponentProps } from 'react';
export type CalendarProps = { export type CalendarProps = {
value?: string; value?: string;
onChange: (value: string) => void; onChange?: (value: string) => void;
min: Date | null; min: Date | null;
max: Date | null; max: Date | null;
} & Omit<ComponentProps<'div'>, 'onChange'>; } & Omit<React.ComponentProps<'div'>, 'onChange'>;

@ -1,26 +1,22 @@
@use '@components/func.scss' as f;
.checkBoxGroup { .checkBoxGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
$margin-bottom: 4px;
.s { .s {
.label { .label {
margin-bottom: $margin-bottom; margin-bottom: 3px;
} }
} }
.m { .m {
.label { .label {
margin-bottom: f.m($margin-bottom); margin-bottom: 5px;
} }
} }
.l { .l {
.label { .label {
margin-bottom: f.l($margin-bottom); margin-bottom: 7px;
} }
} }

@ -2,7 +2,7 @@ import CheckIcon from '@public/images/svg/check.svg';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { ForwardedRef, forwardRef } from 'react'; import React, { ForwardedRef, forwardRef } from 'react';
import { Ripple } from '../animation/ripple/component'; import { Ripple } from '../animation';
import { Label, LabelProps } from '../label'; import { Label, LabelProps } from '../label';
import { RawInput } from '../raw'; import { RawInput } from '../raw';
import styles from './styles.module.scss'; import styles from './styles.module.scss';

@ -1,11 +1,8 @@
@use '@components/func.scss' as f;
.wrapper { .wrapper {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 100%; border-radius: 100%;
cursor: pointer; cursor: pointer;
user-select: none;
&:hover { &:hover {
.checkbox { .checkbox {
@ -57,40 +54,35 @@
transition: all var(--td-100) ease-in-out; transition: all var(--td-100) ease-in-out;
} }
$padding-outer: 4px;
$size: 16px;
$padding-inner: 2px;
$border-radius: 5px;
.s { .s {
padding: $padding-outer; padding: 3px;
.checkbox { .checkbox {
width: $size; width: 16px;
height: $size; height: 16px;
padding: $padding-inner; padding: 2px;
border-radius: $border-radius; border-radius: 5px;
} }
} }
.m { .m {
padding: f.m($padding-outer); padding: 5px;
.checkbox { .checkbox {
width: f.m($size); width: 20px;
height: f.m($size); height: 20px;
padding: f.m($padding-inner); padding: 3px;
border-radius: f.m($border-radius); border-radius: 6px;
} }
} }
.l { .l {
padding: f.l($padding-outer); padding: 7px;
.checkbox { .checkbox {
width: f.l($size); width: 24px;
height: f.l($size); height: 24px;
padding: f.l($padding-inner); padding: 4px;
border-radius: f.l($border-radius); border-radius: 7px;
} }
} }

@ -1,5 +1,3 @@
@use '@components/func.scss' as f;
.comet { .comet {
border-radius: 50%; border-radius: 50%;
animation: spinner-comet 1s infinite linear; animation: spinner-comet 1s infinite linear;
@ -11,37 +9,23 @@
} }
} }
$size: 12px;
$offset: 1.75px;
.s { .s {
width: $size; width: 12px;
height: $size; height: 12px;
mask: radial-gradient( mask: radial-gradient(farthest-side, #0000 calc(100% - 2px), #000 0);
farthest-side,
#0000 calc(100% - $offset),
#000 0
);
} }
.m { .m {
width: f.m($size); width: 16px;
height: f.m($size); height: 16px;
mask: radial-gradient( mask: radial-gradient(farthest-side, #0000 calc(100% - 2.5px), #000 0);
farthest-side,
#0000 calc(100% - f.m($offset)),
#000 0
);
} }
.l { .l {
width: f.l($size); width: 20px;
height: f.l($size); height: 20px;
mask: radial-gradient( mask: radial-gradient(farthest-side, #0000 calc(100% - 3px), #000 0);
farthest-side, }
#0000 calc(100% - f.l($offset)),
#000 0
);}
.onPrimary { .onPrimary {
background: conic-gradient(#0000 10%, var(--clr-on-primary)); background: conic-gradient(#0000 10%, var(--clr-on-primary));

@ -1,8 +1,6 @@
import { ComponentProps } from 'react';
import { Scale } from '../types'; import { Scale } from '../types';
export type CometProps = { export type CometProps = {
scale?: Scale; scale?: Scale;
variant?: 'onPrimary' | 'onSecondary'; variant?: 'onPrimary' | 'onSecondary';
} & ComponentProps<'div'>; } & React.ComponentProps<'div'>;

@ -1,77 +0,0 @@
import React, { useMemo, useState } from 'react';
import { DataGridHeader, DataGridRow } from './components';
import { DataGridProps } from './types';
export function DataGrid<T>({
items,
columns,
getItemKey,
selectedItems,
onItemsSelect,
multiselect,
className,
...props
}: DataGridProps<T>) {
const [allItemsSelected, setAllItemsSelected] = useState<boolean>(false);
const columnsTemplate = useMemo(() => {
const main = columns.map((c) => `${c.width ?? 1}fr`).join(' ');
return `auto ${main}`;
}, []);
const selectedItemsMap = useMemo(() => {
const map: Record<string, T> = {};
for (let i = 0; i < selectedItems.length; i += 1) {
const item = selectedItems[i];
map[String(getItemKey(item))] = item;
}
return map;
}, [selectedItems]);
const handleSelectAllItems = () => {
if (!multiselect) {
return;
}
onItemsSelect?.(allItemsSelected ? [] : [...items]);
setAllItemsSelected(!allItemsSelected);
};
const handleItemSelect = (key: string, item: T) => {
const selected = Boolean(selectedItemsMap[key]);
if (!multiselect) {
onItemsSelect?.(selected ? [] : [item]);
return;
}
onItemsSelect?.(
selected
? selectedItems.filter((i) => key !== getItemKey(i))
: [...selectedItems, item],
);
setAllItemsSelected(false);
};
return (
<div className={className} {...props}>
<DataGridHeader
columns={columns}
allItemsSelected={allItemsSelected}
onSelectAllItems={handleSelectAllItems}
columnsTemplate={columnsTemplate}
/>
{items.map((item) => {
const key = String(getItemKey(item));
return (
<DataGridRow
object={item}
columns={columns}
selected={Boolean(selectedItemsMap[key])}
onSelect={() => handleItemSelect(key, item)}
key={getItemKey(item)}
columnsTemplate={columnsTemplate}
/>
);
})}
</div>
);
}

@ -1,65 +0,0 @@
import { Ripple } from '@components/ui/animation';
import { Checkbox } from '@components/ui/checkbox';
import { RawButton } from '@components/ui/raw';
import { Span } from '@components/ui/span';
import ArrowUpIcon from '@public/images/svg/arrow-up.svg';
import clsx from 'clsx';
import React, { useState } from 'react';
import { DataGridSort } from '../../types';
import styles from './styles.module.scss';
import { DataGridHeaderProps } from './types';
export function DataGridHeader<T>({
columns,
allItemsSelected,
onSelectAllItems,
columnsTemplate,
}: DataGridHeaderProps<T>) {
const [sort, setSort] = useState<DataGridSort>({ order: 'asc', column: '' });
const handleSortButtonClick = (column: string) => {
if (column === sort.column) {
if (sort.order === 'asc') {
setSort({ order: 'desc', column });
} else {
setSort({ order: 'desc', column: '' });
}
} else {
setSort({ order: 'asc', column });
}
};
return (
<header
className={styles.header}
style={{ gridTemplateColumns: columnsTemplate }}
>
<Checkbox
checked={allItemsSelected}
onChange={onSelectAllItems}
label={{ className: styles.checkboxLabel }}
/>
{columns.map((column) => {
const isActive = sort.column === column.name;
const cellClassName = clsx(styles.cell, {
[styles.activeCell]: isActive,
[styles.desc]: isActive && sort.order === 'desc',
});
return (
<RawButton
className={cellClassName}
key={column.name}
onClick={() => handleSortButtonClick(column.name)}
>
<Span color="t300" className={styles.name}>
{column.name}
</Span>
<ArrowUpIcon className={styles.icon} />
<Ripple />
</RawButton>
);
})}
</header>
);
}

@ -1 +0,0 @@
export * from './component';

@ -1,59 +0,0 @@
.header {
display: grid;
}
.checkboxLabel {
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300);
border-top-left-radius: 10px;
}
.cell {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-300);
cursor: pointer;
gap: 10px;
transition: all var(--td-100) ease-in-out;
&:last-of-type {
border-top-right-radius: 10px;
}
@media (hover: hover) {
&:hover {
background-color: var(--clr-layer-300-hover);
}
}
}
.name {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
}
.icon {
width: 12px;
height: 12px;
flex-shrink: 0;
fill: transparent;
transition: all var(--td-100) ease-in-out;
}
.activeCell {
.icon {
fill: var(--clr-text-200);
}
}
.desc {
.icon {
rotate: 180deg;
}
}

@ -1,8 +0,0 @@
import { DataGridColumnConfig } from '../../types';
export type DataGridHeaderProps<T> = {
columns: DataGridColumnConfig<T>[];
allItemsSelected: boolean;
onSelectAllItems: () => void;
columnsTemplate: string;
};

@ -1,32 +0,0 @@
import { Checkbox } from '@components/ui/checkbox';
import { Span } from '@components/ui/span';
import React from 'react';
import styles from './styles.module.scss';
import { DataGridRowProps } from './types';
export function DataGridRow<T>({
object,
columns,
selected,
onSelect,
columnsTemplate,
}: DataGridRowProps<T>) {
return (
<div
className={styles.row}
style={{ gridTemplateColumns: columnsTemplate }}
>
<Checkbox
checked={selected}
label={{ className: styles.checkboxLabel }}
onChange={onSelect}
/>
{columns.map((column) => (
<div className={styles.cell} key={column.name}>
<Span>{column.getText(object)}</Span>
</div>
))}
</div>
);
}

@ -1 +0,0 @@
export * from './component';

@ -1,19 +0,0 @@
.row {
display: grid;
}
.checkboxLabel {
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-200);
}
.cell {
display: flex;
overflow: hidden;
align-items: center;
padding: 10px;
border: solid 1px var(--clr-border-100);
background-color: var(--clr-layer-200);
overflow-wrap: anywhere;
}

@ -1,9 +0,0 @@
import { DataGridColumnConfig } from '../../types';
export type DataGridRowProps<T> = {
object: T;
columns: DataGridColumnConfig<T>[];
selected: boolean;
onSelect: () => void;
columnsTemplate: string;
};

@ -1,2 +0,0 @@
export * from './DataGridHeader';
export * from './DataGridRow';

Some files were not shown because too many files have changed in this diff Show More