From f23c20c1c66b39fb1f49307e08e8593fa708e026 Mon Sep 17 00:00:00 2001 From: Kostya Date: Wed, 9 Sep 2026 10:05:12 +0300 Subject: ui-desktop: build the JavaFX market UI on top of the service Replace the placeholder hello-world window with a working desktop client for the guess market. ServiceEngine wires the in-memory repositories, XML loader, risk, matching, fulfillment and settlement contexts together, and AppState exposes their results as observable JavaFX state for the views to bind to. The FXML views have an events tab and a users tab. The events tab lists events with mechanism and commission filters, and shows each event's price chart, order book ladder, trade history and participants. The users tab shows each user's ledger and positions, and lets them place buy and sell orders. Dialogs cover loading an XML file (in a background task), creating an event and resolving one. Bundle a CSS theme and Nerd Font faces so the UI looks the same on every machine. Teach build.sh to copy each module's src/main/resources into its class output, since FXML, CSS and fonts are loaded from the classpath at runtime. --- .../src/main/java/market/guess/ui/desktop/App.java | 16 +- .../java/market/guess/ui/desktop/AppState.java | 760 +++++++++++++++++++++ .../main/java/market/guess/ui/desktop/AppView.java | 260 +++++++ .../market/guess/ui/desktop/ServiceEngine.java | 71 ++ .../main/java/market/guess/ui/desktop/Skin.java | 23 + .../market/guess/ui/desktop/components/AppTab.java | 6 + .../ui/desktop/components/CommissionFilter.java | 7 + .../guess/ui/desktop/components/EventListCell.java | 29 + .../ui/desktop/components/EventListItemView.java | 51 ++ .../ui/desktop/components/LadderItemView.java | 37 + .../ui/desktop/components/MechanismFilter.java | 7 + .../guess/ui/desktop/components/ModalDialog.java | 8 + .../ui/desktop/components/ParticipantItemView.java | 32 + .../desktop/components/TradeHistoryItemView.java | 38 ++ .../guess/ui/desktop/components/TradeSide.java | 6 + .../ui/desktop/components/UserEventItemView.java | 38 ++ .../guess/ui/desktop/components/UserListCell.java | 29 + .../ui/desktop/components/UserListItemView.java | 34 + .../components/graphic/AnimationToggleGraphic.java | 44 ++ .../components/graphic/EqualizerGraphic.java | 42 ++ .../ui/desktop/components/graphic/Graphic.java | 65 ++ .../desktop/components/graphic/GraphicHelper.java | 64 ++ .../ui/desktop/components/graphic/MarketChart.java | 28 + .../components/graphic/MarketChartGraphic.java | 121 ++++ .../ui/desktop/components/graphic/Motion.java | 59 ++ .../components/graphic/SparklineGraphic.java | 65 ++ .../ui/desktop/controllers/AppController.java | 592 ++++++++++++++++ .../controllers/CreateEventDialogController.java | 163 +++++ .../desktop/controllers/EmptyStateController.java | 14 + .../desktop/controllers/EventsTabController.java | 496 ++++++++++++++ .../desktop/controllers/LoadDialogController.java | 70 ++ .../controllers/ResolveDialogController.java | 68 ++ .../ui/desktop/controllers/UsersTabController.java | 476 +++++++++++++ .../market/guess/ui/desktop/model/BookRow.java | 3 + .../market/guess/ui/desktop/model/ChartPoint.java | 3 + .../market/guess/ui/desktop/model/EventData.java | 81 +++ .../market/guess/ui/desktop/model/OrderBook.java | 16 + .../guess/ui/desktop/model/ParticipantRow.java | 4 + .../market/guess/ui/desktop/model/TradeRow.java | 4 + .../market/guess/ui/desktop/model/UserData.java | 41 ++ .../guess/ui/desktop/model/UserEventRow.java | 4 + .../guess/ui/desktop/task/InitialLoadTask.java | 57 ++ .../java/market/guess/ui/desktop/util/Charts.java | 202 ++++++ .../java/market/guess/ui/desktop/util/Format.java | 21 + .../java/market/guess/ui/desktop/util/Views.java | 41 ++ 45 files changed, 4283 insertions(+), 13 deletions(-) create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/AppState.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/AppView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/ServiceEngine.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/Skin.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/AppTab.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/CommissionFilter.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListCell.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListItemView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/LadderItemView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/MechanismFilter.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/ModalDialog.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/ParticipantItemView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeHistoryItemView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeSide.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/UserEventItemView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListCell.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListItemView.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/AnimationToggleGraphic.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/EqualizerGraphic.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Graphic.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/GraphicHelper.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChart.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChartGraphic.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Motion.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/SparklineGraphic.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/AppController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/CreateEventDialogController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EmptyStateController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EventsTabController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/LoadDialogController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/ResolveDialogController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/controllers/UsersTabController.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/BookRow.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/ChartPoint.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/EventData.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/OrderBook.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/ParticipantRow.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/TradeRow.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/UserData.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/model/UserEventRow.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/task/InitialLoadTask.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/util/Charts.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/util/Format.java create mode 100644 ui-desktop/src/main/java/market/guess/ui/desktop/util/Views.java (limited to 'ui-desktop/src/main/java/market/guess/ui/desktop') diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/App.java b/ui-desktop/src/main/java/market/guess/ui/desktop/App.java index f0b2b7a..0d92c7d 100644 --- a/ui-desktop/src/main/java/market/guess/ui/desktop/App.java +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/App.java @@ -1,22 +1,12 @@ package market.guess.ui.desktop; import javafx.application.Application; -import javafx.scene.Scene; -import javafx.scene.control.Label; -import javafx.scene.layout.StackPane; import javafx.stage.Stage; -public class App extends Application { +public final class App extends Application { @Override - public void start(Stage stage) throws Exception { - var javaVersion = System.getProperty("java.version"); - var openjfxVersion = System.getProperty("javafx.version"); - - var label = - new Label("Hello. JavaFX " + openjfxVersion + ", running on Java " + javaVersion + "."); - var scene = new Scene(new StackPane(label), 640, 480); - - stage.setScene(scene); + public void start(Stage stage) { + new AppView(stage); stage.show(); } } diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/AppState.java b/ui-desktop/src/main/java/market/guess/ui/desktop/AppState.java new file mode 100644 index 0000000..80e472b --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/AppState.java @@ -0,0 +1,760 @@ +package market.guess.ui.desktop; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import market.guess.api.AccountContext; +import market.guess.api.CatalogContext; +import market.guess.api.GuessMarketContext; +import market.guess.model.event.EventDetailDTO; +import market.guess.model.event.EventStatus; +import market.guess.model.event.EventSummaryDTO; +import market.guess.model.event.MechanismType; +import market.guess.model.ledger.LedgerDTO; +import market.guess.ui.desktop.components.AppTab; +import market.guess.ui.desktop.components.CommissionFilter; +import market.guess.ui.desktop.components.MechanismFilter; +import market.guess.ui.desktop.components.ModalDialog; +import market.guess.ui.desktop.components.TradeSide; +import market.guess.ui.desktop.model.BookRow; +import market.guess.ui.desktop.model.ChartPoint; +import market.guess.ui.desktop.model.EventData; +import market.guess.ui.desktop.model.OrderBook; +import market.guess.ui.desktop.model.ParticipantRow; +import market.guess.ui.desktop.model.TradeRow; +import market.guess.ui.desktop.model.UserData; +import market.guess.ui.desktop.model.UserEventRow; +import market.guess.ui.desktop.util.Format; + +/** Reactive UI state backed by JavaFX properties and observable collections. */ +public final class AppState { + private final CatalogContext catalogContext; + private final GuessMarketContext marketContext; + private final AccountContext accountContext; + + private final StringProperty loadedFile = new SimpleStringProperty(null); + + public final ObservableList events = FXCollections.observableArrayList(); + public final ObservableList users = FXCollections.observableArrayList(); + + private final StringProperty selectedEventId = new SimpleStringProperty(null); + private final ObjectProperty selectedEvent = new SimpleObjectProperty<>(); + + private final StringProperty actingUserName = new SimpleStringProperty(null); + private final ObjectProperty actingUser = new SimpleObjectProperty<>(); + + private final StringProperty selectedUserEventId = new SimpleStringProperty("1"); + + private final ObjectProperty filterMethod = + new SimpleObjectProperty<>(MechanismFilter.ALL); + private final ObjectProperty filterStatus = new SimpleObjectProperty<>(null); + private final ObjectProperty filterFee = + new SimpleObjectProperty<>(CommissionFilter.ALL); + + private final ObjectProperty tradeSide = new SimpleObjectProperty<>(TradeSide.BUY); + private final StringProperty tradeQty = new SimpleStringProperty("50"); + private final StringProperty tradePrice = new SimpleStringProperty("0.62"); + private final BooleanProperty tradeOptionYes = new SimpleBooleanProperty(true); + + private final ObjectProperty skin = new SimpleObjectProperty<>(Skin.ROSE_PINE_DAWN); + private final BooleanProperty animationsOn = new SimpleBooleanProperty(true); + + private final ObjectProperty activeTab = new SimpleObjectProperty<>(AppTab.EVENTS); + private final ObjectProperty dialog = new SimpleObjectProperty<>(ModalDialog.NONE); + + private final ObjectProperty createType = + new SimpleObjectProperty<>(MechanismType.LMSR); + private final BooleanProperty createMintingOn = new SimpleBooleanProperty(true); + private final StringProperty resolveSelectedOption = new SimpleStringProperty(null); + + private final StringProperty toast = new SimpleStringProperty(null); + private final StringProperty pendingFile = new SimpleStringProperty(null); + + public AppState() { + this(new ServiceEngine()); + } + + public AppState(ServiceEngine engine) { + this(engine.getCatalogContext(), engine.getMarketContext(), engine.getAccountContext()); + } + + public AppState( + CatalogContext catalogContext, + GuessMarketContext marketContext, + AccountContext accountContext) { + this.catalogContext = catalogContext; + this.marketContext = marketContext; + this.accountContext = accountContext; + + selectedEventId.addListener((obs, oldVal, newVal) -> updateSelectedEvent()); + events.addListener((ListChangeListener) c -> updateSelectedEvent()); + + actingUserName.addListener((obs, oldVal, newVal) -> updateActingUser()); + users.addListener((ListChangeListener) c -> updateActingUser()); + + refreshData(); + } + + public void refreshData() { + var allEventsRes = catalogContext.getAllEvents(); + var allAccountsRes = accountContext.getAllAccounts(); + + var newEventList = new ArrayList(); + if (allEventsRes != null && allEventsRes.isSuccess() && allEventsRes.getData() != null) { + for (var summary : allEventsRes.getData()) { + var detailRes = catalogContext.getEvent(summary.key()); + EventDetailDTO detail = + (detailRes != null && detailRes.isSuccess()) ? detailRes.getData() : null; + newEventList.add(toEventData(summary, detail)); + } + } + this.events.setAll(newEventList); + + var newUserList = new ArrayList(); + if (allAccountsRes != null && allAccountsRes.isSuccess() && allAccountsRes.getData() != null) { + for (var acc : allAccountsRes.getData()) { + newUserList.add(toUserData(acc, newEventList)); + } + } + this.users.setAll(newUserList); + updateSelectedEvent(); + updateActingUser(); + } + + private EventData toEventData(EventSummaryDTO summary, EventDetailDTO detail) { + double contractVal = 0.0; + try { + if (summary.accountBalance() != null) { + contractVal = + Double.parseDouble(summary.accountBalance().replace("$", "").replace(",", "").trim()); + } + } catch (Exception ignored) { + } + + EventData ev = + new EventData( + summary.key(), + summary.displayId(), + summary.name(), + summary.description(), + summary.mechanism(), + summary.status(), + summary.marketMaker(), + summary.commissionPercent(), + summary.commissionTiming(), + contractVal, + summary.mechanism() == MechanismType.LMSR ? 100 : null, + summary.mechanism() == MechanismType.ORDER_BOOK ? BigDecimal.ONE : null); + + if (detail != null) { + if (detail.state() != null && detail.state().markets() != null) { + var markets = detail.state().markets(); + if (markets.size() >= 1) { + try { + ev.yesPrice = new BigDecimal(markets.get(0).price()); + ev.yesShares = (int) Double.parseDouble(markets.get(0).volume()); + } catch (Exception ignored) { + } + } + if (markets.size() >= 2) { + try { + ev.noPrice = new BigDecimal(markets.get(1).price()); + ev.noShares = (int) Double.parseDouble(markets.get(1).volume()); + } catch (Exception ignored) { + } + } + } + if (detail.history() != null) { + int idx = 1; + for (var t : detail.history()) { + boolean isYes = + "YES".equalsIgnoreCase(t.optionName()) + || (summary.optionNames() != null + && !summary.optionNames().isEmpty() + && summary.optionNames().get(0).equalsIgnoreCase(t.optionName())); + int qty = 0; + try { + qty = (int) Long.parseLong(t.quantity()); + } catch (Exception ignored) { + } + ev.trades.add( + new TradeRow(idx++, t.userName(), t.optionName(), isYes, qty, "$" + t.pricePaid())); + } + } + ev.resolvedOption = detail.settledMarket(); + } + + // Baseline and historical chart points + if (detail != null && detail.history() != null && !detail.history().isEmpty()) { + var historyChronological = new ArrayList<>(detail.history()); + Collections.reverse(historyChronological); + + long firstT = parseEpochSecond(historyChronological.get(0).at()); + if (firstT == 0L) firstT = Instant.now().getEpochSecond(); + ev.chart.add(new ChartPoint(firstT - 60, 0.50)); + + for (var t : historyChronological) { + long time = parseEpochSecond(t.at()); + if (time == 0L) time = firstT; + boolean isYes = + "YES".equalsIgnoreCase(t.optionName()) + || (summary.optionNames() != null + && !summary.optionNames().isEmpty() + && summary.optionNames().get(0).equalsIgnoreCase(t.optionName())); + double price = 0.50; + try { + double cost = Double.parseDouble(t.pricePaid().replace("$", "").replace(",", "").trim()); + long qty = Long.parseLong(t.quantity()); + if (qty > 0) { + double unitP = cost / qty; + price = isYes ? unitP : Math.max(0.0, 1.0 - unitP); + } + } catch (Exception ignored) { + price = (ev.yesPrice != null) ? ev.yesPrice.doubleValue() : 0.50; + } + ev.chart.add(new ChartPoint(time, Math.max(0.0, Math.min(1.0, price)))); + } + } else { + long now = Instant.now().getEpochSecond(); + double p = (ev.yesPrice != null) ? ev.yesPrice.doubleValue() : 0.50; + ev.chart.add(new ChartPoint(now - 60, p)); + ev.chart.add(new ChartPoint(now, p)); + } + + // Order books for OB + if (ev.type == MechanismType.ORDER_BOOK) { + String yesLast = ev.yesPrice != null ? Format.money(ev.yesPrice) : "$0.50"; + String noLast = ev.noPrice != null ? Format.money(ev.noPrice) : "$0.50"; + ev.yesBook = new OrderBook("YES", true); + ev.yesBook.last = yesLast; + ev.yesBook.bid = "—"; + ev.yesBook.ask = "—"; + ev.yesBook.mid = yesLast; + ev.yesBook.spread = "—"; + + ev.noBook = new OrderBook("NO", false); + ev.noBook.last = noLast; + ev.noBook.bid = "—"; + ev.noBook.ask = "—"; + ev.noBook.mid = noLast; + ev.noBook.spread = "—"; + + if (detail != null && detail.orderBooks() != null && !detail.orderBooks().isEmpty()) { + for (var obDto : detail.orderBooks()) { + boolean isYes = + "YES".equalsIgnoreCase(obDto.optionKey()) + || "YES".equalsIgnoreCase(obDto.optionName()) + || obDto.optionKey().endsWith(":0") + || obDto.optionKey().equals("0"); + OrderBook target = isYes ? ev.yesBook : ev.noBook; + if (target != null) { + if (obDto.bestBid() != null) target.bid = obDto.bestBid(); + if (obDto.bestAsk() != null) target.ask = obDto.bestAsk(); + if (obDto.spread() != null) target.spread = obDto.spread(); + target.mid = midOf(target.bid, target.ask); + target.rows.clear(); + if (obDto.orders() != null) { + for (var rowDto : obDto.orders()) { + target.rows.add( + new BookRow( + rowDto.side(), + rowDto.user(), + (int) rowDto.quantity(), + rowDto.price(), + rowDto.isBid())); + } + } + } + } + } + } + + // Participants + if (detail != null && detail.participants() != null && !detail.participants().isEmpty()) { + ev.participants.clear(); + for (var p : detail.participants()) { + ev.participants.add( + new ParticipantRow( + p.user(), p.tag(), p.yes(), p.no(), p.value(), p.fees(), p.pnl())); + } + } else if (ev.participants.isEmpty() && ev.mm != null && !ev.mm.isBlank()) { + ev.participants.add(new ParticipantRow(ev.mm, "MM", 0, 0, "$0.00", "$0.00", "$0.00")); + } + + return ev; + } + + private UserData toUserData(LedgerDTO acc, List allEvents) { + double bal = 0.0; + try { + if (acc.balance() != null) { + bal = Double.parseDouble(acc.balance().replace("$", "").replace(",", "").trim()); + } + } catch (Exception ignored) { + } + + boolean isMm = + allEvents.stream().anyMatch(e -> e.mm != null && acc.owner().equalsIgnoreCase(e.mm)); + String role = isMm ? "Market Maker" : "Trader"; + + UserData u = new UserData(acc.owner(), role, bal, acc.blocked(), isMm); + + if (acc.entries() != null && !acc.entries().isEmpty()) { + var entries = acc.entries(); + long firstT = parseEpochSecond(entries.get(0).at()); + if (firstT == 0L) firstT = Instant.now().getEpochSecond(); + + double firstAmount = 0.0; + double firstBalAfter = 0.0; + try { + firstAmount = + Double.parseDouble(entries.get(0).amount().replace("$", "").replace(",", "").trim()); + firstBalAfter = + Double.parseDouble( + entries.get(0).balanceAfter().replace("$", "").replace(",", "").trim()); + } catch (Exception ignored) { + } + double initialBal = firstBalAfter - firstAmount; + u.balanceHistory.add(new ChartPoint(firstT - 60, Math.max(0.0, initialBal))); + + for (var entry : entries) { + long t = parseEpochSecond(entry.at()); + if (t == 0L) t = firstT; + try { + double bAfter = + Double.parseDouble(entry.balanceAfter().replace("$", "").replace(",", "").trim()); + u.balanceHistory.add(new ChartPoint(t, bAfter)); + } catch (Exception ignored) { + } + } + } else { + long now = Instant.now().getEpochSecond(); + u.balanceHistory.add(new ChartPoint(now - 60, bal)); + u.balanceHistory.add(new ChartPoint(now, bal)); + } + + for (var ev : allEvents) { + String typeStr = ev.type == MechanismType.LMSR ? "LMSR" : "Order Book"; + boolean isEvMm = ev.mm != null && acc.owner().equalsIgnoreCase(ev.mm); + long yesShares = 0; + long noShares = 0; + var pOpt = + ev.participants.stream().filter(p -> p.user().equalsIgnoreCase(acc.owner())).findFirst(); + if (pOpt.isPresent()) { + yesShares = pOpt.get().yes(); + noShares = pOpt.get().no(); + } else { + if (isEvMm && ev.type == MechanismType.ORDER_BOOK) { + yesShares += 100; + noShares += 100; + } + for (var trade : ev.trades) { + if (acc.owner().equalsIgnoreCase(trade.user())) { + if (trade.yesOption()) yesShares += trade.shares(); + else noShares += trade.shares(); + } + } + } + // Spec: P/L is reported once the event is closed; until then there is no realised result. + String valStr = + ev.status != EventStatus.SETTLED ? "—" : pOpt.isPresent() ? pOpt.get().pnl() : "$0.00"; + if (isEvMm) { + u.events.add( + new UserEventRow( + ev.id, + ev.name, + "Market Maker", + typeStr, + (int) Math.max(0, yesShares), + (int) Math.max(0, noShares), + valStr)); + } else if (yesShares > 0 || noShares > 0) { + u.events.add( + new UserEventRow( + ev.id, + ev.name, + "Trader", + typeStr, + (int) Math.max(0, yesShares), + (int) Math.max(0, noShares), + valStr)); + } + } + + return u; + } + + /** Mid price between best bid and best ask, or "—" when either side of the book is empty. */ + static String midOf(String bid, String ask) { + try { + var b = new BigDecimal(bid.replace("$", "").trim()); + var a = new BigDecimal(ask.replace("$", "").trim()); + return Format.money(b.add(a).divide(BigDecimal.TWO)); + } catch (RuntimeException e) { + return "—"; + } + } + + public static long parseEpochSecond(String timeStr) { + if (timeStr == null || timeStr.isBlank()) return 0L; + try { + return LocalDateTime.parse(timeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + .atZone(ZoneId.systemDefault()) + .toEpochSecond(); + } catch (Exception e) { + try { + return Instant.parse(timeStr).getEpochSecond(); + } catch (Exception ignored) { + return 0L; + } + } + } + + private void updateSelectedEvent() { + String id = selectedEventId.get(); + EventData ev = (id != null) ? event(id) : null; + if (ev == null && !events.isEmpty()) { + ev = events.get(0); + if (ev != null) { + selectedEventId.set(ev.id); + } + } + selectedEvent.set(null); + selectedEvent.set(ev); + } + + private void updateActingUser() { + String name = actingUserName.get(); + UserData u = (name != null) ? user(name) : null; + if (u == null && !users.isEmpty()) { + u = users.get(0); + if (u != null) { + actingUserName.set(u.name); + } + } + actingUser.set(null); + actingUser.set(u); + } + + public CatalogContext getCatalogContext() { + return catalogContext; + } + + public GuessMarketContext getMarketContext() { + return marketContext; + } + + public AccountContext getAccountContext() { + return accountContext; + } + + public ObservableList getEvents() { + return events; + } + + public ObservableList getUsers() { + return users; + } + + public StringProperty loadedFileProperty() { + return loadedFile; + } + + public String getLoadedFile() { + return loadedFile.get(); + } + + public void setLoadedFile(String file) { + this.loadedFile.set(file); + } + + public StringProperty selectedEventIdProperty() { + return selectedEventId; + } + + public String getSelectedEventId() { + return selectedEventId.get(); + } + + public void setSelectedEventId(String id) { + this.selectedEventId.set(id); + } + + public ObjectProperty selectedEventProperty() { + return selectedEvent; + } + + public EventData selectedEvent() { + return selectedEvent.get(); + } + + public EventData getSelectedEvent() { + return selectedEvent.get(); + } + + public void setSelectedEvent(EventData e) { + this.selectedEvent.set(e); + if (e != null) { + this.selectedEventId.set(e.id); + } + } + + public StringProperty actingUserNameProperty() { + return actingUserName; + } + + public String getActingUserName() { + return actingUserName.get(); + } + + public void setActingUserName(String name) { + this.actingUserName.set(name); + } + + public ObjectProperty actingUserProperty() { + return actingUser; + } + + public UserData actingUser() { + return actingUser.get(); + } + + public UserData getActingUser() { + return actingUser.get(); + } + + public void setActingUser(UserData u) { + this.actingUser.set(u); + if (u != null) { + this.actingUserName.set(u.name); + } + } + + public StringProperty selectedUserEventIdProperty() { + return selectedUserEventId; + } + + public String getSelectedUserEventId() { + return selectedUserEventId.get(); + } + + public void setSelectedUserEventId(String id) { + this.selectedUserEventId.set(id); + } + + public ObjectProperty filterMethodProperty() { + return filterMethod; + } + + public MechanismFilter getFilterMethod() { + return filterMethod.get(); + } + + public void setFilterMethod(MechanismFilter m) { + this.filterMethod.set(m); + } + + public ObjectProperty filterStatusProperty() { + return filterStatus; + } + + public EventStatus getFilterStatus() { + return filterStatus.get(); + } + + public void setFilterStatus(EventStatus s) { + this.filterStatus.set(s); + } + + public ObjectProperty filterFeeProperty() { + return filterFee; + } + + public CommissionFilter getFilterFee() { + return filterFee.get(); + } + + public void setFilterFee(CommissionFilter f) { + this.filterFee.set(f); + } + + public ObjectProperty tradeSideProperty() { + return tradeSide; + } + + public TradeSide getTradeSide() { + return tradeSide.get(); + } + + public void setTradeSide(TradeSide s) { + this.tradeSide.set(s); + } + + public StringProperty tradeQtyProperty() { + return tradeQty; + } + + public String getTradeQty() { + return tradeQty.get(); + } + + public void setTradeQty(String q) { + this.tradeQty.set(q); + } + + public StringProperty tradePriceProperty() { + return tradePrice; + } + + public String getTradePrice() { + return tradePrice.get(); + } + + public void setTradePrice(String p) { + this.tradePrice.set(p); + } + + public BooleanProperty tradeOptionYesProperty() { + return tradeOptionYes; + } + + public boolean isTradeOptionYes() { + return tradeOptionYes.get(); + } + + public void setTradeOptionYes(boolean y) { + this.tradeOptionYes.set(y); + } + + public ObjectProperty skinProperty() { + return skin; + } + + public Skin getSkin() { + return skin.get(); + } + + public void setSkin(Skin s) { + this.skin.set(s); + } + + public BooleanProperty animationsOnProperty() { + return animationsOn; + } + + public boolean isAnimationsOn() { + return animationsOn.get(); + } + + public void setAnimationsOn(boolean a) { + this.animationsOn.set(a); + } + + public ObjectProperty activeTabProperty() { + return activeTab; + } + + public AppTab getActiveTab() { + return activeTab.get(); + } + + public void setActiveTab(AppTab t) { + this.activeTab.set(t); + } + + public ObjectProperty dialogProperty() { + return dialog; + } + + public ModalDialog getDialog() { + return dialog.get(); + } + + public void setDialog(ModalDialog d) { + this.dialog.set(d); + } + + public ObjectProperty createTypeProperty() { + return createType; + } + + public MechanismType getCreateType() { + return createType.get(); + } + + public void setCreateType(MechanismType t) { + this.createType.set(t); + } + + public BooleanProperty createMintingOnProperty() { + return createMintingOn; + } + + public boolean isCreateMintingOn() { + return createMintingOn.get(); + } + + public void setCreateMintingOn(boolean m) { + this.createMintingOn.set(m); + } + + public StringProperty resolveSelectedOptionProperty() { + return resolveSelectedOption; + } + + public String getResolveSelectedOption() { + return resolveSelectedOption.get(); + } + + public void setResolveSelectedOption(String o) { + this.resolveSelectedOption.set(o); + } + + public StringProperty toastProperty() { + return toast; + } + + public String getToast() { + return toast.get(); + } + + public void setToast(String t) { + this.toast.set(t); + } + + public StringProperty pendingFileProperty() { + return pendingFile; + } + + public String getPendingFile() { + return pendingFile.get(); + } + + public void setPendingFile(String f) { + this.pendingFile.set(f); + } + + public EventData event(String id) { + if (id == null) return null; + return events.stream().filter(e -> e.getId().equals(id)).findFirst().orElse(null); + } + + public UserData user(String name) { + if (name == null) return null; + return users.stream().filter(u -> u.getName().equals(name)).findFirst().orElse(null); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/AppView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/AppView.java new file mode 100644 index 0000000..f5cf26f --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/AppView.java @@ -0,0 +1,260 @@ +package market.guess.ui.desktop; + +import javafx.fxml.FXMLLoader; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Scene; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import market.guess.api.AccountContext; +import market.guess.api.CatalogContext; +import market.guess.api.GuessMarketContext; +import market.guess.ui.desktop.controllers.AppController; + +public final class AppView { + private static final double CONTENT_MIN_WIDTH = 1040; + private static final double CONTENT_MIN_HEIGHT = 560; + private static final double WINDOW_MIN_WIDTH = 400; + private static final double WINDOW_MIN_HEIGHT = 300; + + private final Stage stage; + private final CatalogContext catalogContext; + private final GuessMarketContext marketContext; + private final AccountContext accountContext; + private final AppState state; + private AppController controller; + + public AppView(Stage stage) { + this(stage, new ServiceEngine()); + } + + public AppView(Stage stage, ServiceEngine engine) { + this(stage, engine.getCatalogContext(), engine.getMarketContext(), engine.getAccountContext()); + } + + public AppView( + Stage stage, + CatalogContext catalogContext, + GuessMarketContext marketContext, + AccountContext accountContext) { + this.stage = stage; + this.catalogContext = catalogContext; + this.marketContext = marketContext; + this.accountContext = accountContext; + this.state = new AppState(catalogContext, marketContext, accountContext); + stage.initStyle(StageStyle.TRANSPARENT); + initScene(); + } + + public CatalogContext getCatalogContext() { + return catalogContext; + } + + public GuessMarketContext getMarketContext() { + return marketContext; + } + + public AccountContext getAccountContext() { + return accountContext; + } + + private void initScene() { + try { + var loader = new FXMLLoader(getClass().getResource("app_view.fxml")); + StackPane root = loader.load(); + this.controller = loader.getController(); + this.controller.init(this); + + var clip = new Rectangle(); + clip.setArcWidth(22); + clip.setArcHeight(22); + clip.widthProperty().bind(root.widthProperty()); + clip.heightProperty().bind(root.heightProperty()); + root.setClip(clip); + + addResizeHandles(root); + + var scene = new Scene(root, 1280, 800); + scene.setFill(Color.TRANSPARENT); + scene.getStylesheets().add(getClass().getResource("theme.css").toExternalForm()); + stage.setScene(scene); + stage.setTitle("Guess Market"); + stage.setMinWidth(WINDOW_MIN_WIDTH); + stage.setMinHeight(WINDOW_MIN_HEIGHT); + // lookup() can't see ScrollPane content until its skin exists, so go through getContent(). + var appScroll = (ScrollPane) root.lookup(".gm-app-scroll"); + ((Region) appScroll.getContent()).setMinSize(CONTENT_MIN_WIDTH, CONTENT_MIN_HEIGHT); + + controller.refresh(); + } catch (Exception e) { + throw new RuntimeException("Failed to load app_view.fxml", e); + } + } + + public Stage getStage() { + return stage; + } + + public AppState getState() { + return state; + } + + public void show() { + stage.show(); + } + + private static final double RESIZE_MARGIN = 6; + private static final double RESIZE_CORNER = 12; + + private void addResizeHandles(StackPane stack) { + stack + .getChildren() + .addAll( + resizeHandle( + stack, + Cursor.N_RESIZE, + Pos.TOP_CENTER, + -1, + RESIZE_MARGIN, + false, + false, + true, + false), + resizeHandle( + stack, + Cursor.S_RESIZE, + Pos.BOTTOM_CENTER, + -1, + RESIZE_MARGIN, + false, + false, + false, + true), + resizeHandle( + stack, + Cursor.W_RESIZE, + Pos.CENTER_LEFT, + RESIZE_MARGIN, + -1, + true, + false, + false, + false), + resizeHandle( + stack, + Cursor.E_RESIZE, + Pos.CENTER_RIGHT, + RESIZE_MARGIN, + -1, + false, + true, + false, + false), + resizeHandle( + stack, + Cursor.NW_RESIZE, + Pos.TOP_LEFT, + RESIZE_CORNER, + RESIZE_CORNER, + true, + false, + true, + false), + resizeHandle( + stack, + Cursor.NE_RESIZE, + Pos.TOP_RIGHT, + RESIZE_CORNER, + RESIZE_CORNER, + false, + true, + true, + false), + resizeHandle( + stack, + Cursor.SW_RESIZE, + Pos.BOTTOM_LEFT, + RESIZE_CORNER, + RESIZE_CORNER, + true, + false, + false, + true), + resizeHandle( + stack, + Cursor.SE_RESIZE, + Pos.BOTTOM_RIGHT, + RESIZE_CORNER, + RESIZE_CORNER, + false, + true, + false, + true)); + } + + private Region resizeHandle( + StackPane stack, + Cursor cursor, + Pos pos, + double w, + double h, + boolean left, + boolean right, + boolean top, + boolean bottom) { + var r = new Region(); + r.setCursor(cursor); + r.setMouseTransparent(false); + if (w < 0) { + r.prefWidthProperty().bind(stack.widthProperty()); + } else { + r.setPrefWidth(w); + r.setMaxWidth(w); + } + if (h < 0) { + r.prefHeightProperty().bind(stack.heightProperty()); + } else { + r.setPrefHeight(h); + r.setMaxHeight(h); + } + StackPane.setAlignment(r, pos); + + double[] start = new double[6]; + r.setOnMousePressed( + e -> { + if (stage.isMaximized()) return; + start[0] = e.getScreenX(); + start[1] = e.getScreenY(); + start[2] = stage.getX(); + start[3] = stage.getY(); + start[4] = stage.getWidth(); + start[5] = stage.getHeight(); + e.consume(); + }); + r.setOnMouseDragged( + e -> { + if (stage.isMaximized()) return; + double dx = e.getScreenX() - start[0]; + double dy = e.getScreenY() - start[1]; + if (right) stage.setWidth(Math.max(stage.getMinWidth(), start[4] + dx)); + if (bottom) stage.setHeight(Math.max(stage.getMinHeight(), start[5] + dy)); + if (left) { + double newW = Math.max(stage.getMinWidth(), start[4] - dx); + stage.setX(start[2] + (start[4] - newW)); + stage.setWidth(newW); + } + if (top) { + double newH = Math.max(stage.getMinHeight(), start[5] - dy); + stage.setY(start[3] + (start[5] - newH)); + stage.setHeight(newH); + } + e.consume(); + }); + return r; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/ServiceEngine.java b/ui-desktop/src/main/java/market/guess/ui/desktop/ServiceEngine.java new file mode 100644 index 0000000..549991e --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/ServiceEngine.java @@ -0,0 +1,71 @@ +package market.guess.ui.desktop; + +import java.time.Clock; +import market.guess.api.AccountContext; +import market.guess.api.CatalogContext; +import market.guess.api.GuessMarketContext; +import market.guess.service.LocalGuessMarketContext; +import market.guess.service.catalog.LocalCatalogContext; +import market.guess.service.catalog.infrastructure.MarketContext; +import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; +import market.guess.service.catalog.infrastructure.provider.Loader; +import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; +import market.guess.service.catalog.infrastructure.repository.EventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; +import market.guess.service.catalog.infrastructure.repository.UserRepository; +import market.guess.service.fulfillment.FulfillmentContext; +import market.guess.service.fulfillment.LocalFulfillmentContext; +import market.guess.service.ledger.LedgerContext; +import market.guess.service.ledger.LocalAccountContext; +import market.guess.service.matching.LocalMatchingEngine; +import market.guess.service.matching.MatchingEngine; +import market.guess.service.risk.LocalRiskEngine; +import market.guess.service.risk.RiskEngine; +import market.guess.service.settlement.LocalSettlementContext; +import market.guess.service.settlement.SettlementContext; + +public final class ServiceEngine { + private final CatalogContext catalogContext; + private final GuessMarketContext marketContext; + private final AccountContext accountContext; + + public ServiceEngine() { + this(Clock.systemUTC()); + } + + public ServiceEngine(Clock clock) { + EventRepository eventRepo = new InMemoryEventRepository(); + UserRepository userRepo = new InMemoryUserRepository(); + MarketContext marketContextInfrastructure = new MarketContext(eventRepo, userRepo); + EventMapperV2 mapper = new EventMapperV2(); + XMLValidatorV2 validator = new XMLValidatorV2(); + Loader loader = new XMLLoaderV2(eventRepo, userRepo, mapper, validator); + + this.catalogContext = new LocalCatalogContext(loader, marketContextInfrastructure, eventRepo); + this.accountContext = new LocalAccountContext(userRepo); + + LedgerContext ledgerContext = new LedgerContext(userRepo, clock); + RiskEngine risk = new LocalRiskEngine(eventRepo); + MatchingEngine matching = new LocalMatchingEngine(clock); + FulfillmentContext fulfillment = new LocalFulfillmentContext(ledgerContext); + SettlementContext settlement = new LocalSettlementContext(ledgerContext); + + this.marketContext = + new LocalGuessMarketContext( + marketContextInfrastructure, risk, matching, fulfillment, settlement); + } + + public CatalogContext getCatalogContext() { + return catalogContext; + } + + public GuessMarketContext getMarketContext() { + return marketContext; + } + + public AccountContext getAccountContext() { + return accountContext; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/Skin.java b/ui-desktop/src/main/java/market/guess/ui/desktop/Skin.java new file mode 100644 index 0000000..b8e12d7 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/Skin.java @@ -0,0 +1,23 @@ +package market.guess.ui.desktop; + +public enum Skin { + ROSE_PINE_DAWN("Rosé Pine Dawn", "CaskaydiaCove NF"), + CATPPUCCIN("Catppuccin", "JetBrainsMono NF"), + GRUVBOX("Gruvbox", "FiraCode Nerd Font"); + + private final String displayName; + private final String fontFamily; + + Skin(String displayName, String fontFamily) { + this.displayName = displayName; + this.fontFamily = fontFamily; + } + + public String getDisplayName() { + return displayName; + } + + public String getFontFamily() { + return fontFamily; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/AppTab.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/AppTab.java new file mode 100644 index 0000000..c17a0a6 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/AppTab.java @@ -0,0 +1,6 @@ +package market.guess.ui.desktop.components; + +public enum AppTab { + EVENTS, + USERS +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/CommissionFilter.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/CommissionFilter.java new file mode 100644 index 0000000..333a544 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/CommissionFilter.java @@ -0,0 +1,7 @@ +package market.guess.ui.desktop.components; + +public enum CommissionFilter { + ALL, + PURCHASE, + CLOSE +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListCell.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListCell.java new file mode 100644 index 0000000..4f5bb53 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListCell.java @@ -0,0 +1,29 @@ +package market.guess.ui.desktop.components; + +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.ListCell; +import market.guess.ui.desktop.model.EventData; + +public class EventListCell extends ListCell { + private final EventListItemView view = new EventListItemView(); + + public EventListCell() { + setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + selectedProperty().addListener((obs, wasSelected, isNowSelected) -> { + if (getItem() != null) { + view.update(getItem(), isNowSelected); + } + }); + } + + @Override + protected void updateItem(EventData item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + } else { + view.update(item, isSelected()); + setGraphic(view); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListItemView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListItemView.java new file mode 100644 index 0000000..892dbfc --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/EventListItemView.java @@ -0,0 +1,51 @@ +package market.guess.ui.desktop.components; + +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import market.guess.model.event.EventStatus; +import market.guess.model.event.MechanismType; +import market.guess.ui.desktop.model.EventData; +import market.guess.ui.desktop.util.Format; +import market.guess.ui.desktop.util.Views; + +public class EventListItemView extends VBox { + private static final PseudoClass ACTIVE = PseudoClass.getPseudoClass("active"); + private static final PseudoClass CLOSED = PseudoClass.getPseudoClass("closed"); + private static final PseudoClass IDLE = PseudoClass.getPseudoClass("idle"); + + @FXML private Label numLabel; + @FXML private Label titleLabel; + @FXML private Label typeLabel; + @FXML private Label statusLabel; + @FXML private Label feeLabel; + @FXML private Label contractLabel; + + public EventListItemView() { + Views.loadRoot(this, "event_list_item.fxml"); + } + + public EventListItemView(EventData e, boolean selected) { + this(); + update(e, selected); + } + + public void update(EventData e, boolean selected) { + numLabel.setText("#" + e.num); + titleLabel.setText(e.name); + typeLabel.setText(e.type == MechanismType.LMSR ? "LMSR" : "Order Book"); + + statusLabel.setText( + e.status == EventStatus.ACTIVE + ? "ACTIVE" + : e.status == EventStatus.SETTLED ? "CLOSED" : "IDLE"); + statusLabel.pseudoClassStateChanged(ACTIVE, e.status == EventStatus.ACTIVE); + statusLabel.pseudoClassStateChanged(CLOSED, e.status == EventStatus.SETTLED); + statusLabel.pseudoClassStateChanged(IDLE, e.status == EventStatus.DRAFT); + + feeLabel.setText(e.feeText()); + contractLabel.setText(Format.money(e.contract)); + Views.setSelected(this, selected); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/LadderItemView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/LadderItemView.java new file mode 100644 index 0000000..e2ecfbd --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/LadderItemView.java @@ -0,0 +1,37 @@ +package market.guess.ui.desktop.components; + +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import market.guess.ui.desktop.model.BookRow; +import market.guess.ui.desktop.util.Views; + +public class LadderItemView extends HBox { + private static final PseudoClass BID = PseudoClass.getPseudoClass("bid"); + private static final PseudoClass ASK = PseudoClass.getPseudoClass("ask"); + + @FXML private Label priceLabel; + @FXML private Label sideLabel; + @FXML private Label qtyLabel; + @FXML private Label userLabel; + + public LadderItemView() { + Views.loadRoot(this, "ladder_item.fxml"); + } + + public LadderItemView(BookRow row) { + this(); + update(row); + } + + public void update(BookRow r) { + priceLabel.setText(r.price()); + sideLabel.setText(r.side()); + qtyLabel.setText(String.valueOf(r.qty())); + userLabel.setText(r.user()); + + pseudoClassStateChanged(BID, r.isBid()); + pseudoClassStateChanged(ASK, !r.isBid()); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/MechanismFilter.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/MechanismFilter.java new file mode 100644 index 0000000..7a85304 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/MechanismFilter.java @@ -0,0 +1,7 @@ +package market.guess.ui.desktop.components; + +public enum MechanismFilter { + ALL, + LMSR, + ORDER_BOOK +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/ModalDialog.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/ModalDialog.java new file mode 100644 index 0000000..21865a5 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/ModalDialog.java @@ -0,0 +1,8 @@ +package market.guess.ui.desktop.components; + +public enum ModalDialog { + NONE, + LOAD, + CREATE_EVENT, + RESOLVE +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/ParticipantItemView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/ParticipantItemView.java new file mode 100644 index 0000000..2161cfe --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/ParticipantItemView.java @@ -0,0 +1,32 @@ +package market.guess.ui.desktop.components; + +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import market.guess.ui.desktop.model.ParticipantRow; +import market.guess.ui.desktop.util.Views; + +public class ParticipantItemView extends HBox { + @FXML private Label userLabel; + @FXML private Label yesLabel; + @FXML private Label noLabel; + @FXML private Label valueLabel; + @FXML private Label feesLabel; + + public ParticipantItemView() { + Views.loadRoot(this, "participant_item.fxml"); + } + + public ParticipantItemView(ParticipantRow p) { + this(); + update(p); + } + + public void update(ParticipantRow p) { + userLabel.setText(p.user() + (p.tag().isEmpty() ? "" : " (" + p.tag() + ")")); + yesLabel.setText(String.valueOf(p.yes())); + noLabel.setText(String.valueOf(p.no())); + valueLabel.setText(p.value()); + feesLabel.setText(p.fees()); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeHistoryItemView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeHistoryItemView.java new file mode 100644 index 0000000..f00090c --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeHistoryItemView.java @@ -0,0 +1,38 @@ +package market.guess.ui.desktop.components; + +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import market.guess.ui.desktop.model.TradeRow; +import market.guess.ui.desktop.util.Views; + +public class TradeHistoryItemView extends HBox { + private static final PseudoClass YES_STATE = PseudoClass.getPseudoClass("yes"); + private static final PseudoClass NO_STATE = PseudoClass.getPseudoClass("no"); + + @FXML private Label numLabel; + @FXML private Label userLabel; + @FXML private Label optionLabel; + @FXML private Label sharesLabel; + @FXML private Label paidLabel; + + public TradeHistoryItemView() { + Views.loadRoot(this, "trade_history_item.fxml"); + } + + public TradeHistoryItemView(TradeRow t) { + this(); + update(t); + } + + public void update(TradeRow t) { + numLabel.setText("#" + t.n()); + userLabel.setText(t.user()); + optionLabel.setText(t.option()); + optionLabel.pseudoClassStateChanged(YES_STATE, t.yesOption()); + optionLabel.pseudoClassStateChanged(NO_STATE, !t.yesOption()); + sharesLabel.setText(t.shares() + " shares"); + paidLabel.setText(t.paid()); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeSide.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeSide.java new file mode 100644 index 0000000..bf4a5a0 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/TradeSide.java @@ -0,0 +1,6 @@ +package market.guess.ui.desktop.components; + +public enum TradeSide { + BUY, + SELL +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserEventItemView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserEventItemView.java new file mode 100644 index 0000000..0e1dea7 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserEventItemView.java @@ -0,0 +1,38 @@ +package market.guess.ui.desktop.components; + +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import market.guess.ui.desktop.model.UserEventRow; +import market.guess.ui.desktop.util.Views; + +public class UserEventItemView extends HBox { + private static final PseudoClass NEGATIVE = PseudoClass.getPseudoClass("negative"); + + @FXML private Label nameLabel; + @FXML private Label roleLabel; + @FXML private Label typeLabel; + @FXML private Label yesLabel; + @FXML private Label noLabel; + @FXML private Label plLabel; + + public UserEventItemView() { + Views.loadRoot(this, "user_event_item.fxml"); + } + + public UserEventItemView(UserEventRow ev) { + this(); + update(ev); + } + + public void update(UserEventRow ev) { + nameLabel.setText(ev.eventName()); + roleLabel.setText(ev.role()); + typeLabel.setText(ev.type()); + yesLabel.setText(String.valueOf(ev.yes())); + noLabel.setText(String.valueOf(ev.no())); + plLabel.setText(ev.pl()); + plLabel.pseudoClassStateChanged(NEGATIVE, ev.pl().startsWith("-")); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListCell.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListCell.java new file mode 100644 index 0000000..390cc77 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListCell.java @@ -0,0 +1,29 @@ +package market.guess.ui.desktop.components; + +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.ListCell; +import market.guess.ui.desktop.model.UserData; + +public class UserListCell extends ListCell { + private final UserListItemView view = new UserListItemView(); + + public UserListCell() { + setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + selectedProperty().addListener((obs, wasSelected, isNowSelected) -> { + if (getItem() != null) { + view.update(getItem(), isNowSelected); + } + }); + } + + @Override + protected void updateItem(UserData item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + } else { + view.update(item, isSelected()); + setGraphic(view); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListItemView.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListItemView.java new file mode 100644 index 0000000..eb6e7a0 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/UserListItemView.java @@ -0,0 +1,34 @@ +package market.guess.ui.desktop.components; + +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import market.guess.ui.desktop.model.UserData; +import market.guess.ui.desktop.util.Format; +import market.guess.ui.desktop.util.Views; + +public class UserListItemView extends HBox { + private static final PseudoClass NEGATIVE = PseudoClass.getPseudoClass("negative"); + + @FXML private Label nameLabel; + @FXML private Label roleLabel; + @FXML private Label balanceLabel; + + public UserListItemView() { + Views.loadRoot(this, "user_list_item.fxml"); + } + + public UserListItemView(UserData u, boolean selected) { + this(); + update(u, selected); + } + + public void update(UserData u, boolean selected) { + nameLabel.setText(u.name); + roleLabel.setText(u.role); + balanceLabel.setText(Format.money(u.balance)); + balanceLabel.pseudoClassStateChanged(NEGATIVE, u.balance.signum() < 0); + Views.setSelected(this, selected); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/AnimationToggleGraphic.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/AnimationToggleGraphic.java new file mode 100644 index 0000000..77059a9 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/AnimationToggleGraphic.java @@ -0,0 +1,44 @@ +package market.guess.ui.desktop.components.graphic; + +import static market.guess.ui.desktop.components.graphic.GraphicHelper.box; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.styled; + +import java.util.function.DoubleConsumer; +import javafx.scene.Group; +import javafx.scene.shape.Circle; +import javafx.scene.shape.SVGPath; +import javafx.util.Duration; + +/** + * Spinny graphic for the animation toggle button. + * Renders a kinetic 4-blade turbine / pinwheel that spins continuously when active, + * and rests stationary at 0 degrees when inactive. + */ +public final class AnimationToggleGraphic { + private static final Duration CYCLE = Duration.seconds(2.4); + + private static final String BLADES_PATH = + "M 10 10 L 10 2 A 8 8 0 0 1 15.65 4.35 Q 12 8 10 10 Z " + + "M 10 10 L 18 10 A 8 8 0 0 1 15.65 15.65 Q 12 12 10 10 Z " + + "M 10 10 L 10 18 A 8 8 0 0 1 4.35 15.65 Q 8 12 10 10 Z " + + "M 10 10 L 2 10 A 8 8 0 0 1 4.35 4.35 Q 8 8 10 10 Z"; + + private AnimationToggleGraphic() {} + + public static Graphic create() { + var blades = styled(new SVGPath(), "gm-anim-spinner"); + blades.setContent(BLADES_PATH); + + var hub = styled(new Circle(10, 10, 2.2), "gm-anim-spinner-hub"); + + var art = new Group(blades, hub); + + DoubleConsumer render = + p -> { + // Seamless continuous 360-degree rotation when active; rests at 0 when stopped + art.setRotate(p >= 1.0 ? 0.0 : p * 360.0); + }; + + return new Graphic(box(art, 20, 20, "gm-anim-toggle-graphic"), render, CYCLE); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/EqualizerGraphic.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/EqualizerGraphic.java new file mode 100644 index 0000000..16f8735 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/EqualizerGraphic.java @@ -0,0 +1,42 @@ +package market.guess.ui.desktop.components.graphic; + +import static market.guess.ui.desktop.components.graphic.GraphicHelper.at; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.box; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.styled; + +import java.util.function.DoubleConsumer; +import javafx.scene.Group; +import javafx.scene.shape.Rectangle; + +/** + * Events tab LIVE pill equalizer graphic. + */ +public final class EqualizerGraphic { + public static final double BAR_H = 12; + + private EqualizerGraphic() {} + + public static Graphic create() { + var bars = new Rectangle[3]; + for (int i = 0; i < bars.length; i++) { + var bar = new Rectangle(i * 6, 0, 4, BAR_H); + bar.setArcWidth(2); + bar.setArcHeight(2); + bars[i] = styled(bar, "gm-anim-bar"); + } + DoubleConsumer render = + p -> { + // Three offset loops: order flow arriving. The final frame leaves all three legible. + bar(bars[0], at(p, 0, .35, .30, 1, .60, .6, 1, .35)); + bar(bars[1], at(p, 0, .8, .25, .3, .70, 1, 1, .8)); + bar(bars[2], at(p, 0, .55, .45, 1, .80, .4, 1, .55)); + }; + return new Graphic(box(new Group(bars), 16, BAR_H, "gm-anim-equalizer"), render); + } + + /** Grows the bar from a bottom origin — JavaFX scaleY would pivot at the centre. */ + private static void bar(Rectangle r, double scale) { + r.setHeight(BAR_H * scale); + r.setY(BAR_H - BAR_H * scale); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Graphic.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Graphic.java new file mode 100644 index 0000000..a337928 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Graphic.java @@ -0,0 +1,65 @@ +package market.guess.ui.desktop.components.graphic; + +import java.util.function.DoubleConsumer; +import javafx.animation.Animation; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.scene.layout.Pane; +import javafx.util.Duration; + +/** + * Runtime wrapper for an animated decorative graphic driven by a periodic timeline. + */ +public class Graphic { + public static final Duration CYCLE = Duration.seconds(3.6); + + public final Pane node; + + public Pane getNode() { + return node; + } + + private final DoubleConsumer render; + private final DoubleProperty progress = new SimpleDoubleProperty(1); + private final Timeline timeline; + + public Graphic(Pane node, DoubleConsumer render) { + this(node, render, CYCLE); + } + + public Graphic(Pane node, DoubleConsumer render, Duration cycle) { + this.node = node; + this.render = render; + progress.addListener((obs, old, val) -> render.accept(val.doubleValue())); + timeline = + new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(progress, 0.0, Interpolator.LINEAR)), + new KeyFrame(cycle, new KeyValue(progress, 1.0, Interpolator.LINEAR))); + timeline.setCycleCount(Animation.INDEFINITE); + render.accept(1); + } + + public void setPlaying(boolean playing) { + if (playing == (timeline.getStatus() == Animation.Status.RUNNING)) { + return; + } + timeline.stop(); + if (playing) { + timeline.playFromStart(); + } else { + render.accept(1); + } + } + + public Timeline getTimeline() { + return timeline; + } + + public DoubleProperty progressProperty() { + return progress; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/GraphicHelper.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/GraphicHelper.java new file mode 100644 index 0000000..1dcf2ba --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/GraphicHelper.java @@ -0,0 +1,64 @@ +package market.guess.ui.desktop.components.graphic; + +import javafx.animation.Interpolator; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Shape; + +/** + * Shared utility functions for styling shapes, containers, and interpolation math. + */ +public final class GraphicHelper { + private GraphicHelper() {} + + public static T styled(T shape, String styleClass) { + shape.getStyleClass().add(styleClass); + return shape; + } + + public static Circle dot(double r, String styleClass) { + return styled(new Circle(r), styleClass); + } + + public static void place(Circle dot, double[] xy) { + dot.setCenterX(xy[0]); + dot.setCenterY(xy[1]); + } + + public static Pane initBox(Pane pane, Node art, double w, double h, String styleClass) { + pane.getChildren().setAll(art); + pane.getStyleClass().add(styleClass); + pane.setMinSize(w, h); + pane.setPrefSize(w, h); + pane.setMaxSize(w, h); + return pane; + } + + public static Pane box(Node art, double w, double h, String styleClass) { + return initBox(new Pane(), art, w, h, styleClass); + } + + public static double span(double p, double from, double to) { + return Math.max(0, Math.min(1, (p - from) / (to - from))); + } + + public static double eased(double t) { + return Interpolator.EASE_BOTH.interpolate(0.0, 1.0, t); + } + + public static double wrap(double p) { + return p < 0 ? p + 1 : p; + } + + public static double at(double p, double... stops) { + for (int i = 2; i < stops.length; i += 2) { + if (p <= stops[i]) { + double width = stops[i] - stops[i - 2]; + double t = width <= 0 ? 1 : (p - stops[i - 2]) / width; + return stops[i - 1] + t * (stops[i + 1] - stops[i - 1]); + } + } + return stops[stops.length - 1]; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChart.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChart.java new file mode 100644 index 0000000..9de978c --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChart.java @@ -0,0 +1,28 @@ +package market.guess.ui.desktop.components.graphic; + +import javafx.scene.layout.Pane; + +/** + * Custom declarative JavaFX component for the empty-state decorative animated market chart. + * + *

Can be declared directly in FXML: + * + *

{@code
+ * 
+ * }
+ */ +public class MarketChart extends Pane { + private final Graphic graphic; + + public MarketChart() { + this.graphic = MarketChartGraphic.build(this); + } + + public void setPlaying(boolean playing) { + graphic.setPlaying(playing); + } + + public Graphic getGraphic() { + return graphic; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChartGraphic.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChartGraphic.java new file mode 100644 index 0000000..1626f25 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/MarketChartGraphic.java @@ -0,0 +1,121 @@ +package market.guess.ui.desktop.components.graphic; + +import static market.guess.ui.desktop.components.graphic.GraphicHelper.at; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.dot; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.eased; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.initBox; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.place; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.span; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.styled; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.wrap; + +import java.util.function.DoubleConsumer; +import javafx.scene.Group; +import javafx.scene.layout.Pane; +import javafx.scene.shape.CubicCurveTo; +import javafx.scene.shape.Line; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.Path; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.text.Text; +import javafx.scene.transform.Scale; + +/** + * Empty-state market chart decorative graphic. + * Authored in 240x130 viewBox, then scaled to 300x162 display size. + */ +public final class MarketChartGraphic { + public static final double VIEW_SCALE = 1.25; + + /** Cubic control net: p0, c1, c2, p3. Both curves leave the 0.50 midline together. */ + public static final double[] YES_CURVE = {22, 66, 96, 64, 150, 22, 224, 22}; + public static final double[] NO_CURVE = {22, 66, 96, 68, 150, 110, 224, 110}; + + private MarketChartGraphic() {} + + public static Graphic create() { + return build(new Pane()); + } + + public static Graphic build(Pane pane) { + var yes = curve(YES_CURVE, "gm-anim-curve-yes"); + var no = curve(NO_CURVE, "gm-anim-curve-no"); + var yesDot = dot(4, "gm-anim-dot-yes"); + var noDot = dot(4, "gm-anim-dot-no"); + var tickHi = tickLabel("1.0", 24); + var tickLo = tickLabel("0.0", 114); + + var art = + new Group( + rule(20, false), + rule(65, true), + rule(110, false), + styled(new Line(18, 14, 18, 116), "gm-anim-rule"), + tickHi, + tickLo, + yes, + no, + yesDot, + noDot); + art.getTransforms().add(new Scale(VIEW_SCALE, VIEW_SCALE)); + + DoubleConsumer render = + p -> { + double draw = eased(span(p, 0, .55)); + yes.setStrokeDashOffset(300 * (1 - draw)); + no.setStrokeDashOffset(300 * (1 - draw)); + place(yesDot, rideCurve(YES_CURVE, draw)); + place(noDot, rideCurve(NO_CURVE, draw)); + double dotFade = at(p, 0, 0, .08, 1); + yesDot.setOpacity(dotFade); + noDot.setOpacity(dotFade); + tickHi.setOpacity(tickOpacity(p)); + tickLo.setOpacity(tickOpacity(wrap(p - .11))); + }; + initBox(pane, art, 300, 162, "gm-anim-market-chart"); + return new Graphic(pane, render); + } + + private static Path curve(double[] c, String styleClass) { + var path = + new Path(new MoveTo(c[0], c[1]), new CubicCurveTo(c[2], c[3], c[4], c[5], c[6], c[7])); + path.setFill(null); + path.setStrokeWidth(2.5); + path.setStrokeLineCap(StrokeLineCap.ROUND); + // Curve length is ~210, so a 300 dash covers the whole path with room to spare. + path.getStrokeDashArray().setAll(300.0, 300.0); + return styled(path, styleClass); + } + + /** + * The cubic's {x, y} at parameter {@code t}. + */ + public static double[] rideCurve(double[] c, double t) { + double u = 1 - t; + double b0 = u * u * u; + double b1 = 3 * u * u * t; + double b2 = 3 * u * t * t; + double b3 = t * t * t; + return new double[] { + b0 * c[0] + b1 * c[2] + b2 * c[4] + b3 * c[6], b0 * c[1] + b1 * c[3] + b2 * c[5] + b3 * c[7] + }; + } + + private static Line rule(double y, boolean dashed) { + var line = new Line(18, y, 228, y); + if (dashed) { + line.getStrokeDashArray().setAll(3.0, 4.0); + } + return styled(line, "gm-anim-rule"); + } + + private static Text tickLabel(String s, double baseline) { + var text = new Text(0, baseline, s); + return styled(text, "gm-anim-tick"); + } + + /** Axis labels breathe between 25% and 70% opacity. */ + private static double tickOpacity(double p) { + return .25 + .45 * (1 - Math.abs(2 * p - 1)); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Motion.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Motion.java new file mode 100644 index 0000000..4e8b6f2 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/Motion.java @@ -0,0 +1,59 @@ +package market.guess.ui.desktop.components.graphic; + +import javafx.scene.layout.Pane; +import javafx.util.Duration; + +/** + * Coordinating entry point and facade for decorative animated graphics. + */ +public final class Motion { + public static final Duration CYCLE = Graphic.CYCLE; + + public static final double[] YES_CURVE = MarketChartGraphic.YES_CURVE; + public static final double[] NO_CURVE = MarketChartGraphic.NO_CURVE; + public static final double[] SPARK = SparklineGraphic.SPARK; + + private Motion() {} + + // ---- Market Chart ------------------------------------------------------ + + public static Graphic marketChart() { + return MarketChartGraphic.create(); + } + + public static Graphic buildMarketChart(Pane pane) { + return MarketChartGraphic.build(pane); + } + + public static double[] rideCurve(double[] c, double t) { + return MarketChartGraphic.rideCurve(c, t); + } + + // ---- Equalizer --------------------------------------------------------- + + public static Graphic equalizer() { + return EqualizerGraphic.create(); + } + + // ---- Sparkline --------------------------------------------------------- + + public static Graphic sparkline() { + return SparklineGraphic.create(); + } + + public static double[] ridePolyline(double[] pts, double t) { + return SparklineGraphic.ridePolyline(pts, t); + } + + // ---- Animation Toggle -------------------------------------------------- + + public static Graphic animationToggle() { + return AnimationToggleGraphic.create(); + } + + // ---- Interpolation ----------------------------------------------------- + + public static double at(double p, double... stops) { + return GraphicHelper.at(p, stops); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/SparklineGraphic.java b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/SparklineGraphic.java new file mode 100644 index 0000000..8816b54 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/components/graphic/SparklineGraphic.java @@ -0,0 +1,65 @@ +package market.guess.ui.desktop.components.graphic; + +import static market.guess.ui.desktop.components.graphic.GraphicHelper.at; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.box; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.dot; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.eased; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.place; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.span; +import static market.guess.ui.desktop.components.graphic.GraphicHelper.styled; + +import java.util.function.DoubleConsumer; +import javafx.scene.Group; +import javafx.scene.shape.Polyline; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; + +/** + * Users tab balance sparkline graphic. + */ +public final class SparklineGraphic { + public static final double[] SPARK = {2, 20, 14, 16, 26, 22, 38, 11, 50, 14, 62, 5}; + + private SparklineGraphic() {} + + public static Graphic create() { + var line = new Polyline(SPARK); + line.setFill(null); + line.setStrokeWidth(2); + line.setStrokeLineCap(StrokeLineCap.ROUND); + line.setStrokeLineJoin(StrokeLineJoin.ROUND); + line.getStrokeDashArray().setAll(120.0, 120.0); + styled(line, "gm-anim-spark"); + + var dot = dot(2.8, "gm-anim-spark-dot"); + + DoubleConsumer render = + p -> { + double draw = eased(span(p, 0, .70)); + line.setStrokeDashOffset(120 * (1 - draw)); + dot.setOpacity(at(p, 0, 0, .07, 1)); + place(dot, ridePolyline(SPARK, draw)); + }; + return new Graphic(box(new Group(line, dot), 64, 28, "gm-anim-sparkline"), render); + } + + /** The polyline's {x, y} at arc-length fraction {@code t}, so the dot sits on the drawn tip. */ + public static double[] ridePolyline(double[] pts, double t) { + double total = 0; + for (int i = 2; i < pts.length; i += 2) { + total += Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); + } + double target = total * t; + for (int i = 2; i < pts.length; i += 2) { + double seg = Math.hypot(pts[i] - pts[i - 2], pts[i + 1] - pts[i - 1]); + if (target <= seg || i == pts.length - 2) { + double f = seg <= 0 ? 1 : Math.min(target / seg, 1); + return new double[] { + pts[i - 2] + f * (pts[i] - pts[i - 2]), pts[i - 1] + f * (pts[i + 1] - pts[i - 1]) + }; + } + target -= seg; + } + return new double[] {pts[0], pts[1]}; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/AppController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/AppController.java new file mode 100644 index 0000000..c582051 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/AppController.java @@ -0,0 +1,592 @@ +package market.guess.ui.desktop.controllers; + +import java.math.BigDecimal; +import java.nio.file.Paths; +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; +import javafx.beans.binding.Bindings; +import javafx.collections.ListChangeListener; +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TabPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import javafx.stage.FileChooser.ExtensionFilter; +import javafx.util.Duration; +import market.guess.model.event.CreateEventRequest; +import market.guess.model.event.EventStatus; +import market.guess.model.event.MechanismType; +import market.guess.ui.desktop.AppState; +import market.guess.ui.desktop.AppView; +import market.guess.ui.desktop.Skin; +import market.guess.ui.desktop.components.AppTab; +import market.guess.ui.desktop.components.ModalDialog; +import market.guess.ui.desktop.components.graphic.AnimationToggleGraphic; +import market.guess.ui.desktop.components.graphic.Graphic; +import market.guess.ui.desktop.model.EventData; +import market.guess.ui.desktop.model.UserData; +import market.guess.ui.desktop.task.InitialLoadTask; +import market.guess.ui.desktop.util.Format; +import market.guess.ui.desktop.util.Views; + +public class AppController { + private static final PseudoClass SKIN_ROSE_PINE = + PseudoClass.getPseudoClass("skin-rose-pine-dawn"); + private static final PseudoClass SKIN_CATPPUCCIN = PseudoClass.getPseudoClass("skin-catppuccin"); + private static final PseudoClass SKIN_GRUVBOX = PseudoClass.getPseudoClass("skin-gruvbox"); + + private static final Duration TOAST_DURATION = Duration.millis(2500); + private static final Duration FADE_AWAY_DURATION = Duration.millis(500); + private AppView appView; + private double dragOffsetX; + private double dragOffsetY; + private InitialLoadTask currentLoadTask; + private Graphic animationsGraphic; + + @FXML private StackPane rootStack; + @FXML private BorderPane chrome; + @FXML private HBox windowBar; + @FXML private TabPane tabPane; + @FXML private HBox tabInfo; + @FXML private Label loadedFilePathLabel; + @FXML private ComboBox skinComboBox; + @FXML private Button animationsButton; + @FXML private Label balanceValueLabel; + @FXML private Label actingAsLabel; + @FXML private StackPane centerContainer; + @FXML private Label statusBarLabel; + @FXML private Button ejectButton; + @FXML private StackPane dialogContainer; + @FXML private StackPane toastContainer; + @FXML private Label toastLabel; + + // Injected by FXMLLoader from app_view.fxml via + @FXML private Node emptyState; + @FXML private EmptyStateController emptyStateController; + @FXML private Node eventsTab; + @FXML private EventsTabController eventsTabController; + @FXML private Node usersTab; + @FXML private UsersTabController usersTabController; + @FXML private Node loadDialog; + @FXML private LoadDialogController loadDialogController; + @FXML private Node createEventDialog; + @FXML private CreateEventDialogController createEventDialogController; + @FXML private Node resolveDialog; + @FXML private ResolveDialogController resolveDialogController; + + private Node currentlyActiveContent; + + @FXML + private void initialize() { + loadDialogController.setOnCancel(this::cancelLoad); + createEventDialogController.setOnCancel(this::closeDialog); + createEventDialogController.setOnCreate((CreateEventRequest req) -> createEvent(req)); + resolveDialogController.setOnCancel(this::closeDialog); + resolveDialogController.setOnConfirm(this::resolveEvent); + } + + public void init(AppView appView) { + this.appView = appView; + setupWindowDrag(); + setupSkinComboBox(); + setupAnimationsButton(); + initSubViews(); + setupStateBindings(); + applyInitialState(); + } + + private void setupAnimationsButton() { + animationsGraphic = AnimationToggleGraphic.create(); + if (animationsButton != null) { + animationsButton.setGraphic(animationsGraphic.getNode()); + } + } + + private void setupWindowDrag() { + windowBar.setOnMousePressed( + e -> { + dragOffsetX = e.getSceneX(); + dragOffsetY = e.getSceneY(); + }); + windowBar.setOnMouseDragged( + e -> { + appView.getStage().setX(e.getScreenX() - dragOffsetX); + appView.getStage().setY(e.getScreenY() - dragOffsetY); + }); + windowBar.setOnMouseClicked( + e -> { + if (e.getClickCount() == 2) { + var stage = appView.getStage(); + stage.setMaximized(!stage.isMaximized()); + } + }); + } + + private void setupSkinComboBox() { + skinComboBox.getItems().setAll("Rosé Pine Dawn", "Catppuccin", "Gruvbox"); + skinComboBox.setValue("Rosé Pine Dawn"); + } + + private void initSubViews() { + var state = getState(); + eventsTabController.init(state); + eventsTabController.setOnNewEvent(this::openCreateEventDialog); + eventsTabController.setOnOpenEvent(this::openEvent); + eventsTabController.setOnCloseEvent(this::openResolveDialog); + + usersTabController.init(state); + usersTabController.setOnCreateNewEvent(this::openCreateEventDialog); + usersTabController.setOnToast(this::toast); + usersTabController.setOnPlaceOrder(this::placeOrder); + } + + private void setupStateBindings() { + var state = appView.getState(); + + // Declarative property bindings + loadedFilePathLabel + .textProperty() + .bind( + Bindings.when( + state.loadedFileProperty().isNull().or(state.loadedFileProperty().isEmpty())) + .then("") + .otherwise(state.loadedFileProperty())); + + ejectButton.visibleProperty().bind(state.loadedFileProperty().isNotNull()); + ejectButton.managedProperty().bind(ejectButton.visibleProperty()); + + tabInfo.visibleProperty().bind(tabPane.visibleProperty()); + // The header area only exists once the skin is installed; match its height so the + // overlaid acting-as/balance labels stay vertically centred in it. + tabPane + .skinProperty() + .addListener( + (obs, oldV, newV) -> { + var header = (Region) tabPane.lookup(".tab-header-area"); + tabInfo.prefHeightProperty().bind(header.heightProperty()); + }); + tabPane + .getSelectionModel() + .selectedIndexProperty() + .addListener((obs, oldV, newV) -> state.setActiveTab(AppTab.values()[newV.intValue()])); + + // Granular reactive listeners + state.skinProperty().addListener((obs, oldV, newV) -> updateSkin(newV)); + state + .loadedFileProperty() + .addListener( + (obs, oldV, newV) -> { + updateCenterContent(); + updateStatusBar(); + }); + state.activeTabProperty().addListener((obs, oldV, newV) -> updateTabs(newV)); + state.dialogProperty().addListener((obs, oldV, newV) -> updateDialogOverlay()); + state.toastProperty().addListener((obs, oldV, newV) -> updateToastOverlay()); + state.animationsOnProperty().addListener((obs, oldV, newV) -> updateAnimations(newV)); + state.actingUserProperty().addListener((obs, oldV, newV) -> updateActingUserDisplay()); + state.actingUserNameProperty().addListener((obs, oldV, newV) -> updateActingUserDisplay()); + + state.getEvents().addListener((ListChangeListener) c -> updateStatusBar()); + state.getUsers().addListener((ListChangeListener) c -> updateStatusBar()); + } + + public AppState getState() { + return appView != null ? appView.getState() : null; + } + + /** + * Refreshes UI state across all components. Kept for lifecycle compatibility; delegates to + * targeted update methods. + */ + public void refresh() { + applyInitialState(); + } + + private void applyInitialState() { + var state = getState(); + if (state == null) return; + + updateSkin(state.getSkin()); + updateAnimations(state.isAnimationsOn()); + updateTabs(state.getActiveTab()); + updateActingUserDisplay(); + updateCenterContent(); + updateStatusBar(); + updateDialogOverlay(); + updateToastOverlay(); + } + + private void updateSkin(Skin skin) { + if (skin == null) return; + rootStack.pseudoClassStateChanged(SKIN_ROSE_PINE, skin == Skin.ROSE_PINE_DAWN); + rootStack.pseudoClassStateChanged(SKIN_CATPPUCCIN, skin == Skin.CATPPUCCIN); + rootStack.pseudoClassStateChanged(SKIN_GRUVBOX, skin == Skin.GRUVBOX); + + String comboVal = + switch (skin) { + case CATPPUCCIN -> "Catppuccin"; + case GRUVBOX -> "Gruvbox"; + default -> "Rosé Pine Dawn"; + }; + if (!comboVal.equals(skinComboBox.getValue())) { + skinComboBox.setValue(comboVal); + } + } + + private void updateAnimations(boolean animOn) { + Views.setSelected(animationsButton, animOn); + if (animationsGraphic != null) { + animationsGraphic.setPlaying(animOn); + } + if (emptyStateController != null) { + emptyStateController.setAnimating(animOn); + } + } + + private void updateTabs(AppTab tab) { + // ponytail: tab order in app_view.fxml must match AppTab declaration order + tabPane.getSelectionModel().select(tab.ordinal()); + updateCenterContent(); + } + + private void updateActingUserDisplay() { + var state = getState(); + if (state == null) return; + var actor = state.getActingUser(); + if (actor == null) return; + balanceValueLabel.setText(Format.money(actor.balance)); + actingAsLabel.setText( + "acting as " + (state.getActingUserName() == null ? "" : state.getActingUserName())); + } + + private void updateCenterContent() { + var state = getState(); + if (state == null) return; + + boolean showEmpty = (state.getLoadedFile() == null); + boolean showEvents = !showEmpty && (state.getActiveTab() == AppTab.EVENTS); + boolean showUsers = !showEmpty && (state.getActiveTab() == AppTab.USERS); + + Node activeContent = showEmpty ? emptyState : (showEvents ? eventsTab : usersTab); + + emptyState.setVisible(showEmpty); + emptyState.setManaged(showEmpty); + + tabPane.setVisible(!showEmpty); + tabPane.setManaged(!showEmpty); + + if (showEvents && eventsTabController != null) { + eventsTabController.refresh(); + } + if (showUsers && usersTabController != null) { + usersTabController.refresh(); + } + + if (currentlyActiveContent != activeContent) { + Node prev = currentlyActiveContent; + currentlyActiveContent = activeContent; + if (state.isAnimationsOn() && state.getLoadedFile() != null && prev != null) { + var fade = new FadeTransition(FADE_AWAY_DURATION, activeContent); + fade.setFromValue(0); + fade.setToValue(1); + fade.play(); + } + } + } + + private void updateStatusBar() { + var state = getState(); + if (state == null || statusBarLabel == null) return; + + if (state.getLoadedFile() == null) { + statusBarLabel.setText(" IDLE  "); + } else { + String fileName = Paths.get(state.getLoadedFile()).getFileName().toString(); + statusBarLabel.setText( + "  " + + fileName + + "   " + + state.getEvents().size() + + " events   " + + state.getUsers().size() + + " users"); + } + } + + private void updateDialogOverlay() { + var state = getState(); + if (state == null) return; + + boolean showDialog = (state.getDialog() != ModalDialog.NONE); + dialogContainer.setVisible(showDialog); + dialogContainer.setMouseTransparent(!showDialog); + + boolean isLoad = (state.getDialog() == ModalDialog.LOAD); + loadDialog.setVisible(isLoad); + loadDialog.setManaged(isLoad); + + boolean isCreate = (state.getDialog() == ModalDialog.CREATE_EVENT); + createEventDialog.setVisible(isCreate); + createEventDialog.setManaged(isCreate); + + boolean isResolve = (state.getDialog() == ModalDialog.RESOLVE); + resolveDialog.setVisible(isResolve); + resolveDialog.setManaged(isResolve); + } + + private void updateToastOverlay() { + var state = getState(); + if (state == null) return; + + if (state.getToast() != null) { + toastLabel.setText(state.getToast()); + toastLabel.setVisible(true); + } else { + toastLabel.setVisible(false); + } + } + + // ---- FXML action handlers ---------------------------------------------- + + @FXML + private void handleClose() { + appView.getStage().close(); + } + + @FXML + private void handleMinimize() { + appView.getStage().setIconified(true); + } + + @FXML + private void handleMaximize() { + var stage = appView.getStage(); + stage.setMaximized(!stage.isMaximized()); + } + + @FXML + private void handleSkinChanged() { + String val = skinComboBox.getValue(); + if (val == null) return; + Skin skin = + switch (val) { + case "Catppuccin" -> Skin.CATPPUCCIN; + case "Gruvbox" -> Skin.GRUVBOX; + default -> Skin.ROSE_PINE_DAWN; + }; + var state = appView.getState(); + if (state != null && state.getSkin() != skin) { + state.setSkin(skin); + } + } + + @FXML + private void handleAnimationsToggled() { + appView.getState().setAnimationsOn(!appView.getState().isAnimationsOn()); + } + + @FXML + private void handleLoadFile() { + startLoad(); + } + + @FXML + private void handleEjectFile() { + appView.getState().setLoadedFile(null); + } + + // ---- Public Actions --------------------------------------------------- + + public void toast(String msg) { + appView.getState().setToast(msg); + var pause = new PauseTransition(TOAST_DURATION); + pause.setOnFinished(e -> appView.getState().setToast(null)); + pause.play(); + } + + public void startLoad() { + var stage = appView.getStage(); + var fc = new FileChooser(); + fc.setTitle("Load events file"); + fc.getExtensionFilters().add(new ExtensionFilter("GuessMarket Files", "*.xml")); + var picked = stage.getScene() == null ? null : fc.showOpenDialog(stage); + if (picked == null) { + return; + } + var state = appView.getState(); + state.setPendingFile(picked.getAbsolutePath()); + + var loadTask = + new InitialLoadTask(appView.getCatalogContext(), Paths.get(state.getPendingFile())); + this.currentLoadTask = loadTask; + + loadTask.setOnRunning( + e -> { + state.setDialog(ModalDialog.LOAD); + loadDialogController.show(loadTask, state.getPendingFile()); + }); + + loadTask.setOnCancelled( + e -> { + this.currentLoadTask = null; + loadDialogController.reset(); + state.setDialog(ModalDialog.NONE); + }); + + loadTask.setOnFailed( + e -> { + this.currentLoadTask = null; + loadDialogController.reset(); + cancelLoad(); + Throwable ex = loadTask.getException(); + String msg = + (ex != null && ex.getMessage() != null && !ex.getMessage().isBlank()) + ? ex.getMessage() + : "Failed to load file."; + toast(msg); + }); + + loadTask.setOnSucceeded( + e -> { + this.currentLoadTask = null; + loadDialogController.reset(); + if (state.getDialog() != ModalDialog.LOAD) return; + var loadResult = loadTask.getValue(); + state.setLoadedFile(loadResult.source()); + state.refreshData(); + state.setDialog(ModalDialog.NONE); + toast( + "File loaded  " + + loadResult.eventsLoaded() + + " events, " + + state.getUsers().size() + + " users"); + }); + + var thread = new Thread(loadTask); + thread.setDaemon(true); + thread.start(); + } + + public void cancelLoad() { + if (currentLoadTask != null) { + currentLoadTask.cancel(); + currentLoadTask = null; + } + loadDialogController.reset(); + appView.getState().setDialog(ModalDialog.NONE); + } + + public void openCreateEventDialog() { + var state = appView.getState(); + state.setCreateType(MechanismType.LMSR); + createEventDialogController.reset(); + state.setDialog(ModalDialog.CREATE_EVENT); + } + + public void closeDialog() { + appView.getState().setDialog(ModalDialog.NONE); + } + + public void openResolveDialog() { + var state = appView.getState(); + state.setResolveSelectedOption(null); + resolveDialogController.reset(); + state.setDialog(ModalDialog.RESOLVE); + } + + public void createEvent(CreateEventRequest req) { + var state = appView.getState(); + state.setCreateType(req.mechanism()); + var requestWithMM = + new CreateEventRequest( + req.name(), + req.description(), + req.mechanism(), + req.commissionPercent(), + req.commissionTiming(), + req.mmUserName() != null ? req.mmUserName() : state.getActingUserName(), + req.liquidityB(), + req.baseValueD(), + req.options(), + req.allowMinting()); + var res = appView.getCatalogContext().createEvent(requestWithMM); + if (res.isSuccess()) { + state.refreshData(); + state.setDialog(ModalDialog.NONE); + state.setSelectedEventId(res.getData().summary().key()); + toast("Event created — you are its market maker"); + } else { + toast("Failed to create event: " + res.getMessage()); + } + } + + public void resolveEvent(String option) { + var state = appView.getState(); + var e = state.getSelectedEvent(); + if (e == null) return; + var res = appView.getMarketContext().settleEvent(state.getActingUserName(), e.id, option); + if (res.isSuccess()) { + state.refreshData(); + state.setDialog(ModalDialog.NONE); + toast("Event resolved as " + option); + } else { + toast("Failed to resolve event: " + res.getMessage()); + } + } + + public void openEvent(EventData e) { + var state = appView.getState(); + if (e.status == EventStatus.DRAFT) { + var res = appView.getCatalogContext().openEvent(e.id); + if (!res.isSuccess()) { + toast("Failed to open event: " + res.getMessage()); + return; + } + state.refreshData(); + } + // The trade panel lives on the Users tab and follows the selected event. + state.setSelectedEventId(e.id); + state.setActiveTab(AppTab.USERS); + toast("Trading " + e.name + " as " + state.getActingUserName()); + } + + public void placeOrder(String eventKey, String optionKey, BigDecimal price, long quantity) { + placeOrder(eventKey, optionKey, "BUY", price, quantity); + } + + public void placeOrder( + String eventKey, String optionKey, String side, BigDecimal price, long quantity) { + var state = appView.getState(); + var res = + appView + .getMarketContext() + .placeOrder(state.getActingUserName(), eventKey, optionKey, side, price, quantity); + if (res.isSuccess()) { + state.refreshData(); + var receipt = res.getData(); + boolean isBuy = "BUY".equalsIgnoreCase(side); + if (receipt.trades() == null || receipt.trades().isEmpty()) { + toast("Resting " + side + " order placed: " + quantity + " @ " + Format.money(price)); + } else { + toast( + "Order placed  " + + (isBuy ? "Paid " : "Received ") + + Format.money(new BigDecimal(receipt.totalPaid())) + + " for " + + quantity + + " " + + optionKey); + } + } else { + toast("Trade failed: " + res.getMessage()); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/CreateEventDialogController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/CreateEventDialogController.java new file mode 100644 index 0000000..5649145 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/CreateEventDialogController.java @@ -0,0 +1,163 @@ +package market.guess.ui.desktop.controllers; + +import java.math.BigDecimal; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import market.guess.model.event.CommissionTiming; +import market.guess.model.event.CreateEventRequest; +import market.guess.model.event.MechanismType; +import market.guess.ui.desktop.util.Format; +import market.guess.ui.desktop.util.Views; + +public class CreateEventDialogController { + private Runnable onCancel; + private Consumer onCreateRequest; + private BiConsumer onCreate; + private MechanismType selectedType = MechanismType.LMSR; + + @FXML private TextField nameField; + @FXML private TextArea descArea; + @FXML private Button lmsrBtn; + @FXML private Button orderBookBtn; + @FXML private ComboBox feeCollectionCombo; + @FXML private TextField feePercentField; + @FXML private Label paramLabel; + @FXML private TextField paramField; + @FXML private TextField opt1Field; + @FXML private TextField opt2Field; + @FXML private CheckBox mintingCheckBox; + @FXML private Label infoNoteLabel; + + @FXML + private void initialize() { + feeCollectionCombo.getItems().setAll("On purchase", "On close"); + feeCollectionCombo.setValue("On purchase"); + updateTypeSelection(); + } + + public void setOnCancel(Runnable onCancel) { + this.onCancel = onCancel; + } + + public void setOnCreate(Consumer onCreateRequest) { + this.onCreateRequest = onCreateRequest; + } + + public void setOnCreate(BiConsumer onCreate) { + this.onCreate = onCreate; + } + + public void reset() { + nameField.setText("Will the summit happen before Q4?"); + descArea.setText( + "Resolves YES if a joint summit is publicly confirmed and held before October 1st."); + feeCollectionCombo.setValue("On purchase"); + feePercentField.setText("5"); + opt1Field.setText("YES"); + opt2Field.setText("NO"); + selectedType = MechanismType.LMSR; + updateTypeSelection(); + } + + private void updateTypeSelection() { + Views.setSelected(lmsrBtn, selectedType == MechanismType.LMSR); + Views.setSelected(orderBookBtn, selectedType == MechanismType.ORDER_BOOK); + + boolean isLmsr = selectedType == MechanismType.LMSR; + paramLabel.setText(isLmsr ? "Liquidity b (integer)" : "Base value d ($)"); + paramField.setText(isLmsr ? "100" : "1.00"); + mintingCheckBox.setVisible(!isLmsr); + + String infoText = + isLmsr + ? "Opening this event will move " + + Format.money(BigDecimal.valueOf(100 * Math.log(2))) + + " of subsidy from your account into the contract account (b  ln 2)." + : "Opening this event will buy the initial share inventory from your account into the" + + " contract account."; + infoNoteLabel.setText(infoText); + } + + @FXML + private void handleSelectLmsr() { + selectedType = MechanismType.LMSR; + updateTypeSelection(); + } + + @FXML + private void handleSelectOrderBook() { + selectedType = MechanismType.ORDER_BOOK; + updateTypeSelection(); + } + + @FXML + private void handleCancel() { + if (onCancel != null) { + onCancel.run(); + } + } + + @FXML + private void handleCreate() { + String name = nameField.getText() != null ? nameField.getText().trim() : ""; + String desc = descArea.getText() != null ? descArea.getText().trim() : ""; + MechanismType type = selectedType; + CommissionTiming timing = "On close".equalsIgnoreCase(feeCollectionCombo.getValue()) + ? CommissionTiming.ON_CLOSE + : CommissionTiming.ON_PURCHASE; + + int fee = 5; + try { + fee = Integer.parseInt(feePercentField.getText().trim()); + if (fee < 0) fee = 0; + if (fee > 90) fee = 90; + } catch (Exception ignored) { + } + + Integer b = null; + BigDecimal d = null; + if (type == MechanismType.LMSR) { + try { + b = Integer.parseInt(paramField.getText().trim()); + } catch (Exception ignored) { + b = 100; + } + } else { + try { + d = new BigDecimal(paramField.getText().trim()); + } catch (Exception ignored) { + d = BigDecimal.ONE; + } + } + + String opt1 = opt1Field.getText() != null && !opt1Field.getText().isBlank() ? opt1Field.getText().trim() : "YES"; + String opt2 = opt2Field.getText() != null && !opt2Field.getText().isBlank() ? opt2Field.getText().trim() : "NO"; + boolean minting = mintingCheckBox.isSelected(); + + var request = new CreateEventRequest( + name, + desc, + type, + fee, + timing, + null, + b, + d, + List.of(opt1, opt2), + minting); + + if (onCreateRequest != null) { + onCreateRequest.accept(request); + } else if (onCreate != null) { + onCreate.accept(name, type); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EmptyStateController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EmptyStateController.java new file mode 100644 index 0000000..9b01095 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EmptyStateController.java @@ -0,0 +1,14 @@ +package market.guess.ui.desktop.controllers; + +import javafx.fxml.FXML; +import market.guess.ui.desktop.components.graphic.MarketChart; + +public class EmptyStateController { + @FXML private MarketChart marketChart; + + public void setAnimating(boolean animating) { + if (marketChart != null) { + marketChart.setPlaying(animating); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EventsTabController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EventsTabController.java new file mode 100644 index 0000000..e71be40 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/EventsTabController.java @@ -0,0 +1,496 @@ +package market.guess.ui.desktop.controllers; + +import java.util.ArrayList; +import java.util.function.Consumer; +import javafx.collections.ListChangeListener; +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.chart.LineChart; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import market.guess.model.event.CommissionTiming; +import market.guess.model.event.EventStatus; +import market.guess.model.event.MechanismType; +import market.guess.ui.desktop.AppState; +import market.guess.ui.desktop.components.CommissionFilter; +import market.guess.ui.desktop.components.EventListCell; +import market.guess.ui.desktop.components.LadderItemView; +import market.guess.ui.desktop.components.MechanismFilter; +import market.guess.ui.desktop.components.ParticipantItemView; +import market.guess.ui.desktop.components.TradeHistoryItemView; +import market.guess.ui.desktop.components.graphic.EqualizerGraphic; +import market.guess.ui.desktop.components.graphic.Graphic; +import market.guess.ui.desktop.model.ChartPoint; +import market.guess.ui.desktop.model.EventData; +import market.guess.ui.desktop.model.OrderBook; +import market.guess.ui.desktop.model.ParticipantRow; +import market.guess.ui.desktop.model.TradeRow; +import market.guess.ui.desktop.util.Charts; +import market.guess.ui.desktop.util.Format; +import market.guess.ui.desktop.util.Views; + +public class EventsTabController { + private static final PseudoClass ACTIVE = PseudoClass.getPseudoClass("active"); + private static final PseudoClass CLOSED = PseudoClass.getPseudoClass("closed"); + private static final PseudoClass IDLE = PseudoClass.getPseudoClass("idle"); + + private AppState state; + private Runnable onNewEvent; + private Consumer onOpenEvent; + private Runnable onCloseEvent; + private Graphic equalizer; + private boolean updatingSelection = false; + + @FXML private Button methodFilterAllBtn; + @FXML private Button methodFilterLmsrBtn; + @FXML private Button methodFilterObBtn; + @FXML private Button statusFilterAllBtn; + @FXML private Button statusFilterNotStartedBtn; + @FXML private Button statusFilterActiveBtn; + @FXML private Button statusFilterClosedBtn; + @FXML private Button feeFilterAllBtn; + @FXML private Button feeFilterPurchaseBtn; + @FXML private Button feeFilterCloseBtn; + + @FXML private ListView eventListContainer; + @FXML private Label eventCountLabel; + + @FXML private VBox eventDetailCard; + @FXML private Label eventNameLabel; + @FXML private Label eventDescLabel; + @FXML private Button openEventBtn; + @FXML private Button closeEventBtn; + + @FXML private Label metaMethodLabel; + @FXML private Label metaStatusLabel; + @FXML private HBox livePill; + @FXML private StackPane liveBarsBox; + @FXML private Label liveLabel; + @FXML private Label metaMmLabel; + @FXML private Label metaFeeLabel; + @FXML private Label metaContractLabel; + + @FXML private VBox lmsrSection; + @FXML private Label lmsrYesPriceLabel; + @FXML private ProgressBar lmsrYesProgressBar; + @FXML private Label lmsrYesSharesLabel; + @FXML private Label lmsrNoPriceLabel; + @FXML private ProgressBar lmsrNoProgressBar; + @FXML private Label lmsrNoSharesLabel; + @FXML private Label lmsrChartSubLabel; + @FXML private LineChart lmsrChart; + @FXML private VBox tradeHistoryRowsContainer; + + @FXML private VBox orderBookSection; + @FXML private Label yesBookCountLabel; + @FXML private Label yesLastLabel; + @FXML private Label yesMidLabel; + @FXML private Label yesBestBidLabel; + @FXML private Label yesBestAskLabel; + @FXML private Label yesSpreadLabel; + @FXML private VBox yesBookLadderList; + + @FXML private Label noBookCountLabel; + @FXML private Label noLastLabel; + @FXML private Label noMidLabel; + @FXML private Label noBestBidLabel; + @FXML private Label noBestAskLabel; + @FXML private Label noSpreadLabel; + @FXML private VBox noBookLadderList; + + @FXML private Label obChartSubLabel; + @FXML private LineChart obChart; + + @FXML private VBox participantsRowsContainer; + @FXML private HBox resolvedBanner; + @FXML private Label resolvedBannerText; + + public void init(AppState state) { + this.state = state; + equalizer = EqualizerGraphic.create(); + liveBarsBox.getChildren().setAll(equalizer.getNode()); + + eventListContainer.setCellFactory(lv -> new EventListCell()); + eventListContainer + .getSelectionModel() + .selectedItemProperty() + .addListener( + (obs, oldVal, newVal) -> { + if (updatingSelection) return; + if (newVal != null && state != null) { + state.setSelectedEvent(newVal); + } + }); + + state + .selectedEventProperty() + .addListener( + (obs, oldVal, newVal) -> { + if (newVal != null) { + if (!newVal.equals(eventListContainer.getSelectionModel().getSelectedItem())) { + updatingSelection = true; + try { + eventListContainer.getSelectionModel().select(newVal); + } finally { + updatingSelection = false; + } + } + } + refreshDetailCard(newVal); + }); + + state.filterMethodProperty().addListener((obs, oldVal, newVal) -> refreshListAndFilters()); + state.filterStatusProperty().addListener((obs, oldVal, newVal) -> refreshListAndFilters()); + state.filterFeeProperty().addListener((obs, oldVal, newVal) -> refreshListAndFilters()); + state + .getEvents() + .addListener( + (ListChangeListener) + c -> { + refreshListAndFilters(); + refreshDetailCard(state.selectedEvent()); + }); + state + .animationsOnProperty() + .addListener( + (obs, oldVal, newVal) -> { + var sel = state.selectedEvent(); + boolean isActive = sel != null && sel.status == EventStatus.ACTIVE; + equalizer.setPlaying(newVal && isActive); + }); + } + + public void setOnNewEvent(Runnable onNewEvent) { + this.onNewEvent = onNewEvent; + } + + public void setOnOpenEvent(Consumer onOpenEvent) { + this.onOpenEvent = onOpenEvent; + } + + public void setOnCloseEvent(Runnable onCloseEvent) { + this.onCloseEvent = onCloseEvent; + } + + public void refresh() { + if (state == null) return; + refreshListAndFilters(); + refreshDetailCard(state.selectedEvent()); + } + + private void refreshListAndFilters() { + if (state == null) return; + + // 1. Update filter button selections + Views.setSelected(methodFilterAllBtn, state.getFilterMethod() == MechanismFilter.ALL); + Views.setSelected(methodFilterLmsrBtn, state.getFilterMethod() == MechanismFilter.LMSR); + Views.setSelected(methodFilterObBtn, state.getFilterMethod() == MechanismFilter.ORDER_BOOK); + + Views.setSelected(statusFilterAllBtn, state.getFilterStatus() == null); + Views.setSelected(statusFilterNotStartedBtn, state.getFilterStatus() == EventStatus.DRAFT); + Views.setSelected(statusFilterActiveBtn, state.getFilterStatus() == EventStatus.ACTIVE); + Views.setSelected(statusFilterClosedBtn, state.getFilterStatus() == EventStatus.SETTLED); + + Views.setSelected(feeFilterAllBtn, state.getFilterFee() == CommissionFilter.ALL); + Views.setSelected(feeFilterPurchaseBtn, state.getFilterFee() == CommissionFilter.PURCHASE); + Views.setSelected(feeFilterCloseBtn, state.getFilterFee() == CommissionFilter.CLOSE); + + // 2. Update list + var filtered = + state.getEvents().stream() + .filter( + e -> + state.getFilterMethod() == MechanismFilter.ALL + || (state.getFilterMethod() == MechanismFilter.LMSR + && e.type == MechanismType.LMSR) + || (state.getFilterMethod() == MechanismFilter.ORDER_BOOK + && e.type == MechanismType.ORDER_BOOK)) + .filter(e -> state.getFilterStatus() == null || e.status == state.getFilterStatus()) + .filter( + e -> + state.getFilterFee() == CommissionFilter.ALL + || (state.getFilterFee() == CommissionFilter.PURCHASE + && e.feeMode == CommissionTiming.ON_PURCHASE) + || (state.getFilterFee() == CommissionFilter.CLOSE + && e.feeMode == CommissionTiming.ON_CLOSE)) + .toList(); + + updatingSelection = true; + try { + eventListContainer.getItems().setAll(filtered); + eventCountLabel.setText( + filtered.size() + " of " + state.getEvents().size() + " events shown"); + + var sel = state.selectedEvent(); + EventData match = null; + if (sel != null) { + for (var e : filtered) { + if (e.id.equals(sel.id)) { + match = e; + break; + } + } + } + if (match != null) { + eventListContainer.getSelectionModel().select(match); + } else if (!filtered.isEmpty()) { + eventListContainer.getSelectionModel().select(0); + } + } finally { + updatingSelection = false; + } + } + + private void refreshDetailCard(EventData selected) { + if (selected == null) { + eventDetailCard.setVisible(false); + return; + } + eventDetailCard.setVisible(true); + + eventNameLabel.setText(selected.num + ". " + selected.name); + eventDescLabel.setText(selected.desc); + + // Open = go trade: any unblocked user on an ACTIVE event, or the MM activating their DRAFT. + var actor = state.getActingUser(); + boolean isMm = selected.mm.equals(state.getActingUserName()); + boolean canOpen = + actor != null + && !actor.blocked + && (selected.status == EventStatus.ACTIVE + || (selected.status == EventStatus.DRAFT && isMm)); + boolean canClose = + selected.status == EventStatus.ACTIVE && selected.mm.equals(state.getActingUserName()); + + openEventBtn.setText(selected.status == EventStatus.DRAFT ? "Start Event" : "Trade"); + openEventBtn.setVisible(canOpen); + openEventBtn.setManaged(canOpen); + closeEventBtn.setVisible(canClose); + closeEventBtn.setManaged(canClose); + + // Meta fields + metaMethodLabel.setText(selected.type == MechanismType.LMSR ? "LMSR" : "Order Book"); + metaStatusLabel.setText(selected.status.name()); + + // LIVE pill: only an ACTIVE market needs to look like order flow is arriving. + boolean isActive = selected.status == EventStatus.ACTIVE; + boolean isEnded = selected.status == EventStatus.SETTLED; + liveLabel.setText(isActive ? "LIVE" : isEnded ? "ENDED" : "IDLE"); + livePill.pseudoClassStateChanged(ACTIVE, isActive); + livePill.pseudoClassStateChanged(CLOSED, isEnded); + livePill.pseudoClassStateChanged(IDLE, !isActive && !isEnded); + equalizer.setPlaying(state.isAnimationsOn() && isActive); + metaMmLabel.setText(selected.mm); + metaFeeLabel.setText(selected.feeText()); + metaContractLabel.setText(Format.money(selected.contract)); + + // Sections + boolean isLmsr = selected.type == MechanismType.LMSR; + lmsrSection.setVisible(isLmsr); + lmsrSection.setManaged(isLmsr); + orderBookSection.setVisible(!isLmsr); + orderBookSection.setManaged(!isLmsr); + + if (isLmsr) { + refreshLmsr(selected); + } else { + refreshOrderBook(selected); + } + + // Trade history table + var tRows = new ArrayList(selected.trades.size()); + for (var t : selected.trades) { + tRows.add(buildTradeHistoryRow(t)); + } + tradeHistoryRowsContainer.getChildren().setAll(tRows); + + // Participants table + var pRows = new ArrayList(selected.participants.size()); + for (var p : selected.participants) { + pRows.add(buildParticipantRow(p)); + } + participantsRowsContainer.getChildren().setAll(pRows); + + // Resolved banner + boolean isClosed = selected.status == EventStatus.SETTLED; + resolvedBanner.setVisible(isClosed); + resolvedBanner.setManaged(isClosed); + if (isClosed) { + resolvedBannerText.setText( + "Winning option: " + + selected.resolvedOption + + "  " + + selected.yesShares + + " YES / " + + selected.noShares + + " NO shares bought  contract emptied to holders, " + + selected.feePercent + + "% fee moved to " + + selected.mm + + "."); + } + } + + private void refreshLmsr(EventData e) { + lmsrYesPriceLabel.setText(Format.money(e.yesPrice)); + lmsrYesProgressBar.setProgress(e.yesPrice.doubleValue()); + lmsrYesSharesLabel.setText( + e.yesShares + " shares  p = " + Format.prob(e.yesPrice.doubleValue())); + + lmsrNoPriceLabel.setText(Format.money(e.noPrice)); + lmsrNoProgressBar.setProgress(e.noPrice.doubleValue()); + lmsrNoSharesLabel.setText(e.noShares + " shares  p = " + Format.prob(e.noPrice.doubleValue())); + + lmsrChartSubLabel.setText("YES / NO  LMSR b = " + e.liquidityB); + var noPts = new ArrayList(); + for (var p : e.chart) noPts.add(new ChartPoint(p.x(), 1.0 - p.y())); + Charts.setDualSeries(lmsrChart, "YES", e.chart, "NO", noPts); + } + + private void refreshOrderBook(EventData e) { + obChartSubLabel.setText( + "last trade YES  base d = " + (e.baseValueD != null ? Format.money(e.baseValueD) : "—")); + Charts.setSingleSeries(obChart, "value", e.chart); + + populateBookSide( + e.yesBook, + yesBookCountLabel, + yesLastLabel, + yesBestBidLabel, + yesBestAskLabel, + yesMidLabel, + yesSpreadLabel, + yesBookLadderList); + populateBookSide( + e.noBook, + noBookCountLabel, + noLastLabel, + noBestBidLabel, + noBestAskLabel, + noMidLabel, + noSpreadLabel, + noBookLadderList); + } + + private void populateBookSide( + OrderBook book, + Label countLabel, + Label lastLabel, + Label bestBidLabel, + Label bestAskLabel, + Label midLabel, + Label spreadLabel, + VBox ladderList) { + if (book == null) { + ladderList.getChildren().clear(); + countLabel.setText("0 orders"); + for (var l : new Label[] {lastLabel, bestBidLabel, bestAskLabel, midLabel, spreadLabel}) { + l.setText("—"); + } + return; + } + + countLabel.setText(book.rows.size() + " orders"); + lastLabel.setText(book.last != null ? book.last : "—"); + bestBidLabel.setText(book.bid != null ? book.bid : "—"); + bestAskLabel.setText(book.ask != null ? book.ask : "—"); + midLabel.setText(book.mid != null ? book.mid : "—"); + spreadLabel.setText(book.spread != null ? book.spread : "—"); + + var rows = new ArrayList(book.rows.size()); + for (var r : book.rows) { + rows.add(new LadderItemView(r)); + } + ladderList.getChildren().setAll(rows); + } + + private Node buildTradeHistoryRow(TradeRow t) { + return new TradeHistoryItemView(t); + } + + private Node buildParticipantRow(ParticipantRow p) { + return new ParticipantItemView(p); + } + + // ---- FXML Handlers ---------------------------------------------------- + + @FXML + private void handleMethodAll() { + if (state != null) state.setFilterMethod(MechanismFilter.ALL); + } + + @FXML + private void handleMethodLmsr() { + if (state != null) state.setFilterMethod(MechanismFilter.LMSR); + } + + @FXML + private void handleMethodOb() { + if (state != null) state.setFilterMethod(MechanismFilter.ORDER_BOOK); + } + + @FXML + private void handleStatusAll() { + if (state != null) state.setFilterStatus(null); + } + + @FXML + private void handleStatusNotStarted() { + if (state != null) state.setFilterStatus(EventStatus.DRAFT); + } + + @FXML + private void handleStatusActive() { + if (state != null) state.setFilterStatus(EventStatus.ACTIVE); + } + + @FXML + private void handleStatusClosed() { + if (state != null) state.setFilterStatus(EventStatus.SETTLED); + } + + @FXML + private void handleFeeAll() { + if (state != null) state.setFilterFee(CommissionFilter.ALL); + } + + @FXML + private void handleFeePurchase() { + if (state != null) state.setFilterFee(CommissionFilter.PURCHASE); + } + + @FXML + private void handleFeeClose() { + if (state != null) state.setFilterFee(CommissionFilter.CLOSE); + } + + @FXML + private void handleNewEvent() { + if (onNewEvent != null) { + onNewEvent.run(); + } + } + + @FXML + private void handleOpenEvent() { + if (state != null) { + var e = state.selectedEvent(); + if (e != null && onOpenEvent != null) { + onOpenEvent.accept(e); + } + } + } + + @FXML + private void handleCloseEvent() { + if (onCloseEvent != null) { + onCloseEvent.run(); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/LoadDialogController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/LoadDialogController.java new file mode 100644 index 0000000..e23cd9e --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/LoadDialogController.java @@ -0,0 +1,70 @@ +package market.guess.ui.desktop.controllers; + +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.fxml.FXML; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.util.Duration; +import market.guess.ui.desktop.task.InitialLoadTask; + +public final class LoadDialogController { + private Runnable onCancel; + private Timeline currentFillAnimation; + + @FXML private Label pathLabel; + @FXML private ProgressBar progressBar; + + public void setOnCancel(Runnable onCancel) { + this.onCancel = onCancel; + } + + public void reset() { + if (currentFillAnimation != null) { + currentFillAnimation.stop(); + currentFillAnimation = null; + } + if (progressBar != null) { + progressBar.setProgress(0.0); + } + } + + public void show(InitialLoadTask task, String pendingFile) { + reset(); + if (pathLabel != null) { + pathLabel.setText(pendingFile); + } + task.progressProperty() + .addListener( + (observable, oldValue, newValue) -> { + if (newValue == null) return; + double val = newValue.doubleValue(); + if (val < 0) { + if (progressBar != null) { + progressBar.setProgress(0.0); + } + return; + } + if (progressBar != null) { + if (currentFillAnimation != null) { + currentFillAnimation.stop(); + } + currentFillAnimation = + new Timeline( + new KeyFrame( + Duration.millis(100), + new KeyValue(progressBar.progressProperty(), val))); + currentFillAnimation.play(); + } + }); + } + + @FXML + private void handleCancel() { + reset(); + if (onCancel != null) { + onCancel.run(); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/ResolveDialogController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/ResolveDialogController.java new file mode 100644 index 0000000..4183f7f --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/ResolveDialogController.java @@ -0,0 +1,68 @@ +package market.guess.ui.desktop.controllers; + +import java.util.function.Consumer; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import market.guess.ui.desktop.util.Views; + +public class ResolveDialogController { + private Runnable onCancel; + private Consumer onConfirm; + private String selectedOption = null; + + @FXML private Button resolveYesBtn; + @FXML private Button resolveNoBtn; + @FXML private Button confirmBtn; + + @FXML + private void initialize() { + updateSelection(); + } + + public void setOnCancel(Runnable onCancel) { + this.onCancel = onCancel; + } + + public void setOnConfirm(Consumer onConfirm) { + this.onConfirm = onConfirm; + } + + public void reset() { + selectedOption = null; + updateSelection(); + } + + private void updateSelection() { + Views.setSelected(resolveYesBtn, "YES".equals(selectedOption)); + Views.setSelected(resolveNoBtn, "NO".equals(selectedOption)); + boolean hasChoice = selectedOption != null; + confirmBtn.setDisable(!hasChoice); + confirmBtn.setText("Resolve as " + (hasChoice ? selectedOption : "…")); + } + + @FXML + private void handleSelectYes() { + selectedOption = "YES"; + updateSelection(); + } + + @FXML + private void handleSelectNo() { + selectedOption = "NO"; + updateSelection(); + } + + @FXML + private void handleCancel() { + if (onCancel != null) { + onCancel.run(); + } + } + + @FXML + private void handleConfirm() { + if (onConfirm != null && selectedOption != null) { + onConfirm.accept(selectedOption); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/UsersTabController.java b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/UsersTabController.java new file mode 100644 index 0000000..6211337 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/controllers/UsersTabController.java @@ -0,0 +1,476 @@ +package market.guess.ui.desktop.controllers; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.function.Consumer; +import javafx.collections.ListChangeListener; +import javafx.css.PseudoClass; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import market.guess.model.event.EventStatus; +import market.guess.model.event.MechanismType; +import market.guess.ui.desktop.AppState; +import market.guess.ui.desktop.components.TradeSide; +import market.guess.ui.desktop.components.UserEventItemView; +import market.guess.ui.desktop.components.UserListCell; +import market.guess.ui.desktop.components.graphic.Graphic; +import market.guess.ui.desktop.components.graphic.SparklineGraphic; +import market.guess.ui.desktop.model.UserData; +import market.guess.ui.desktop.model.UserEventRow; +import market.guess.ui.desktop.util.Charts; +import market.guess.ui.desktop.util.Format; +import market.guess.ui.desktop.util.Views; + +public class UsersTabController { + private static final PseudoClass BLOCKED = PseudoClass.getPseudoClass("blocked"); + private static final PseudoClass MM = PseudoClass.getPseudoClass("mm"); + private static final PseudoClass TRADER = PseudoClass.getPseudoClass("trader"); + private static final PseudoClass NEGATIVE = PseudoClass.getPseudoClass("negative"); + private static final PseudoClass ERROR = PseudoClass.getPseudoClass("error"); + + @FunctionalInterface + public interface OrderPlacer { + void placeOrder( + String eventKey, String optionKey, String side, BigDecimal price, long quantity); + } + + private AppState state; + private Runnable onCreateNewEvent; + private Consumer onToast; + private OrderPlacer onPlaceOrder; + private Graphic sparkline; + private boolean updatingSelection = false; + + @FXML private StackPane sparklineBox; + @FXML private ListView userListContainer; + @FXML private VBox userDetailCard; + @FXML private Label userNameLabel; + @FXML private Label userRolePill; + @FXML private Label userSubLabel; + @FXML private Label userBalanceLabel; + + @FXML private HBox blockedBanner; + @FXML private LineChart balanceChart; + @FXML private VBox participationRowsContainer; + + @FXML private VBox tradePanel; + @FXML private Label noTradeEventLabel; + @FXML private VBox tradeContentBox; + @FXML private Label tradeEventTitle; + @FXML private Label tradeEventSub; + @FXML private VBox tradeYesOptCard; + @FXML private Label tradeYesPriceLabel; + @FXML private Label tradeYesHeldLabel; + @FXML private VBox tradeNoOptCard; + @FXML private Label tradeNoPriceLabel; + @FXML private Label tradeNoHeldLabel; + @FXML private Button tradeBuyBtn; + @FXML private Button tradeSellBtn; + @FXML private TextField tradeQtyField; + @FXML private TextField tradePriceField; + @FXML private Label tradeCostLabel; + @FXML private Button tradeSubmitBtn; + @FXML private Label tradeHintLabel; + + public void init(AppState state) { + this.state = state; + sparkline = SparklineGraphic.create(); + sparklineBox.getChildren().setAll(sparkline.node); + + userListContainer.setCellFactory(lv -> new UserListCell()); + userListContainer + .getSelectionModel() + .selectedItemProperty() + .addListener( + (obs, oldVal, newVal) -> { + if (updatingSelection) return; + if (newVal != null && state != null) { + state.setActingUser(newVal); + } + }); + + state + .actingUserProperty() + .addListener( + (obs, oldVal, newVal) -> { + if (newVal != null) { + if (!newVal.equals(userListContainer.getSelectionModel().getSelectedItem())) { + updatingSelection = true; + try { + userListContainer.getSelectionModel().select(newVal); + } finally { + updatingSelection = false; + } + } + } + refreshUserDetail(newVal); + }); + + state + .selectedEventProperty() + .addListener( + (obs, oldVal, newVal) -> { + refreshTradePanel(state.actingUser()); + }); + + state + .tradeOptionYesProperty() + .addListener((obs, oldVal, newVal) -> refreshTradePanel(state.actingUser())); + + state + .tradeSideProperty() + .addListener((obs, oldVal, newVal) -> refreshTradePanel(state.actingUser())); + + state + .getUsers() + .addListener( + (ListChangeListener) + c -> { + refreshUserList(); + refreshUserDetail(state.actingUser()); + }); + state.animationsOnProperty().addListener((obs, oldVal, newVal) -> sparkline.setPlaying(newVal)); + + tradeQtyField + .textProperty() + .addListener( + (obs, o, n) -> { + if (state != null) state.setTradeQty(n); + validateTradeInputs(); + }); + + tradePriceField + .textProperty() + .addListener( + (obs, o, n) -> { + if (state != null) state.setTradePrice(n); + validateTradeInputs(); + }); + } + + public void setOnCreateNewEvent(Runnable onCreateNewEvent) { + this.onCreateNewEvent = onCreateNewEvent; + } + + public void setOnToast(Consumer onToast) { + this.onToast = onToast; + } + + public void setOnPlaceOrder(OrderPlacer onPlaceOrder) { + this.onPlaceOrder = onPlaceOrder; + } + + public void refresh() { + if (state == null) return; + refreshUserList(); + refreshUserDetail(state.actingUser()); + } + + private void refreshUserList() { + if (state == null) return; + updatingSelection = true; + try { + userListContainer.getItems().setAll(state.getUsers()); + var actor = state.actingUser(); + UserData match = null; + if (actor != null) { + for (var u : state.getUsers()) { + if (u.name.equals(actor.name)) { + match = u; + break; + } + } + } + if (match != null) { + userListContainer.getSelectionModel().select(match); + } else if (!state.getUsers().isEmpty()) { + userListContainer.getSelectionModel().select(0); + } + } finally { + updatingSelection = false; + } + } + + private void refreshUserDetail(UserData actor) { + if (actor == null) { + userDetailCard.setVisible(false); + return; + } + userDetailCard.setVisible(true); + + userNameLabel.setText(actor.name); + userRolePill.setText(actor.blocked ? "BLOCKED" : actor.isMm ? "MARKET MAKER" : "TRADER"); + userRolePill.pseudoClassStateChanged(BLOCKED, actor.blocked); + userRolePill.pseudoClassStateChanged(MM, !actor.blocked && actor.isMm); + userRolePill.pseudoClassStateChanged(TRADER, !actor.blocked && !actor.isMm); + + userSubLabel.setText("Active in " + actor.events.size() + " events"); + userBalanceLabel.setText(Format.money(actor.balance)); + userBalanceLabel.pseudoClassStateChanged(NEGATIVE, actor.balance.signum() < 0); + sparkline.node.pseudoClassStateChanged(NEGATIVE, actor.balance.signum() < 0); + sparkline.setPlaying(state.isAnimationsOn()); + + // Blocked Banner + blockedBanner.setVisible(actor.blocked); + blockedBanner.setManaged(actor.blocked); + + // Balance Chart + Charts.setSingleSeries(balanceChart, "balance", actor.balanceHistory); + if (balanceChart.getYAxis() instanceof NumberAxis yAxis) { + Charts.scaleYAxis(yAxis, actor.balanceHistory, actor.balance.doubleValue()); + } + + // Participation Table + var evRows = new ArrayList(actor.events.size()); + for (var ev : actor.events) { + evRows.add(buildParticipationRow(ev)); + } + participationRowsContainer.getChildren().setAll(evRows); + + // Trade Panel + refreshTradePanel(actor); + } + + private void refreshTradePanel(UserData actor) { + if (state == null) return; + var e = state.selectedEvent(); + + if (e == null) { + noTradeEventLabel.setVisible(true); + noTradeEventLabel.setManaged(true); + tradeContentBox.setVisible(false); + tradeContentBox.setManaged(false); + return; + } + + noTradeEventLabel.setVisible(false); + noTradeEventLabel.setManaged(false); + tradeContentBox.setVisible(true); + tradeContentBox.setManaged(true); + + tradeEventTitle.setText(e.num + ". " + e.name); + tradeEventSub.setText( + (e.type == MechanismType.LMSR ? "LMSR" : "Order Book") + + "  Status: " + + e.status + + "  Fee: " + + e.feeText()); + + int yesHeld = 0, noHeld = 0; + if (actor != null && actor.events != null) { + for (var ev : actor.events) { + if (ev.eventId().equals(e.id)) { + yesHeld = ev.yes(); + noHeld = ev.no(); + break; + } + } + } + + tradeYesPriceLabel.setText(Format.money(e.yesPrice)); + tradeYesHeldLabel.setText("You hold " + yesHeld + " shares"); + + tradeNoPriceLabel.setText(Format.money(e.noPrice)); + tradeNoHeldLabel.setText("You hold " + noHeld + " shares"); + + Views.setSelected(tradeYesOptCard, state.isTradeOptionYes()); + Views.setSelected(tradeNoOptCard, !state.isTradeOptionYes()); + + Views.setSelected(tradeBuyBtn, state.getTradeSide() == TradeSide.BUY); + Views.setSelected(tradeSellBtn, state.getTradeSide() == TradeSide.SELL); + + if (tradeQtyField.getText().isEmpty()) tradeQtyField.setText(state.getTradeQty()); + if (tradePriceField.getText().isEmpty()) tradePriceField.setText(state.getTradePrice()); + + tradePriceField.setVisible(e.type == MechanismType.ORDER_BOOK); + tradePriceField.setManaged(e.type == MechanismType.ORDER_BOOK); + + tradeSubmitBtn.setText( + state.getTradeSide() == TradeSide.BUY ? "Place Buy Order" : "Place Sell Order"); + + validateTradeInputs(); + } + + private boolean validateTradeInputs() { + if (state == null) return false; + var e = state.selectedEvent(); + var actor = state.actingUser(); + if (e == null || actor == null) return false; + + boolean valid = true; + String qtyText = tradeQtyField.getText() != null ? tradeQtyField.getText().trim() : ""; + long qty = 0; + if (qtyText.isEmpty()) { + valid = false; + tradeQtyField.pseudoClassStateChanged(ERROR, true); + tradeHintLabel.setText("Please enter the number of shares."); + } else { + try { + qty = Long.parseLong(qtyText); + if (qty <= 0) { + valid = false; + tradeQtyField.pseudoClassStateChanged(ERROR, true); + tradeHintLabel.setText("Shares must be a positive number."); + } else { + tradeQtyField.pseudoClassStateChanged(ERROR, false); + } + } catch (NumberFormatException nfe) { + valid = false; + tradeQtyField.pseudoClassStateChanged(ERROR, true); + tradeHintLabel.setText("Shares must be a valid integer."); + } + } + + BigDecimal price = null; + if (e.type == MechanismType.ORDER_BOOK) { + String priceText = tradePriceField.getText() != null ? tradePriceField.getText().trim() : ""; + if (priceText.isEmpty()) { + valid = false; + tradePriceField.pseudoClassStateChanged(ERROR, true); + if (valid) { + tradeHintLabel.setText("Please enter a price per share."); + } + } else { + try { + price = new BigDecimal(priceText); + if (price.compareTo(BigDecimal.ZERO) <= 0) { + valid = false; + tradePriceField.pseudoClassStateChanged(ERROR, true); + tradeHintLabel.setText("Price per share must be positive."); + } else { + tradePriceField.pseudoClassStateChanged(ERROR, false); + } + } catch (Exception nfe) { + valid = false; + tradePriceField.pseudoClassStateChanged(ERROR, true); + tradeHintLabel.setText("Price must be a valid decimal number."); + } + } + } else { + tradePriceField.pseudoClassStateChanged(ERROR, false); + price = state.isTradeOptionYes() ? e.yesPrice : e.noPrice; + } + + if (valid && qty > 0 && price != null) { + BigDecimal cost = price.multiply(BigDecimal.valueOf(qty)); + tradeCostLabel.setText(Format.money(cost)); + if (actor.blocked) { + tradeHintLabel.setText("Trading is disabled because this account is blocked."); + tradeSubmitBtn.setDisable(true); + } else if (e.status != EventStatus.ACTIVE) { + tradeHintLabel.setText("Trading is only available for ACTIVE events."); + tradeSubmitBtn.setDisable(true); + } else if (state.getTradeSide() == TradeSide.SELL) { + int held = 0; + if (actor.events != null) { + for (var evRow : actor.events) { + if (evRow.eventId().equals(e.id)) { + held = state.isTradeOptionYes() ? evRow.yes() : evRow.no(); + break; + } + } + } + if (!actor.isMm && qty > held) { + tradeHintLabel.setText("Cannot sell " + qty + " shares; you only hold " + held + "."); + tradeSubmitBtn.setDisable(true); + } else { + tradeHintLabel.setText("Sell order will match existing bids or rest as an ask."); + tradeSubmitBtn.setDisable(false); + } + } else { + if (actor.balance.compareTo(cost) < 0) { + tradeHintLabel.setText( + "Insufficient balance. Required: " + + Format.money(cost) + + ", available: " + + Format.money(actor.balance)); + tradeSubmitBtn.setDisable(true); + } else { + tradeHintLabel.setText( + e.type == MechanismType.ORDER_BOOK + ? "Buy order will match existing asks or rest as a bid." + : "Orders execute immediately against the market maker."); + tradeSubmitBtn.setDisable(false); + } + } + } else { + tradeCostLabel.setText("—"); + tradeSubmitBtn.setDisable(true); + } + + return valid; + } + + private Node buildParticipationRow(UserEventRow ev) { + var row = new UserEventItemView(ev); + row.setOnMouseClicked( + e -> { + if (state != null) { + state.setSelectedEventId(ev.eventId()); + } + }); + return row; + } + + // ---- FXML Handlers ---------------------------------------------------- + + @FXML + private void handleCreateNewEvent() { + if (onCreateNewEvent != null) { + onCreateNewEvent.run(); + } + } + + @FXML + private void handleSelectTradeYes() { + if (state != null) state.setTradeOptionYes(true); + } + + @FXML + private void handleSelectTradeNo() { + if (state != null) state.setTradeOptionYes(false); + } + + @FXML + private void handleTradeBuy() { + if (state != null) state.setTradeSide(TradeSide.BUY); + } + + @FXML + private void handleTradeSell() { + if (state != null) state.setTradeSide(TradeSide.SELL); + } + + @FXML + private void handleTradeSubmit() { + if (state == null) return; + var e = state.selectedEvent(); + var u = state.actingUser(); + if (e == null || u == null) return; + if (!validateTradeInputs()) return; + + long qty = Long.parseLong(tradeQtyField.getText().trim()); + BigDecimal price = + (e.type == MechanismType.ORDER_BOOK) + ? new BigDecimal(tradePriceField.getText().trim()) + : (state.isTradeOptionYes() ? e.yesPrice : e.noPrice); + + String optionKey = state.isTradeOptionYes() ? "YES" : "NO"; + String side = state.getTradeSide() == TradeSide.SELL ? "SELL" : "BUY"; + + if (onPlaceOrder != null) { + onPlaceOrder.placeOrder(e.id, optionKey, side, price, qty); + } else if (onToast != null) { + onToast.accept("Order placed: " + side + " " + qty + " " + optionKey + " on #" + e.num); + } + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/BookRow.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/BookRow.java new file mode 100644 index 0000000..8162419 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/BookRow.java @@ -0,0 +1,3 @@ +package market.guess.ui.desktop.model; + +public record BookRow(String side, String user, int qty, String price, boolean isBid) {} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/ChartPoint.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/ChartPoint.java new file mode 100644 index 0000000..9dd845c --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/ChartPoint.java @@ -0,0 +1,3 @@ +package market.guess.ui.desktop.model; + +public record ChartPoint(double x, double y) {} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/EventData.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/EventData.java new file mode 100644 index 0000000..f3d0bc4 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/EventData.java @@ -0,0 +1,81 @@ +package market.guess.ui.desktop.model; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import market.guess.model.event.CommissionTiming; +import market.guess.model.event.EventStatus; +import market.guess.model.event.MechanismType; + +public final class EventData { + public final String id; + + public final int num; + + public final String name; + public final String desc; + public final MechanismType type; + public EventStatus status; + public final String mm; + public final int feePercent; + public final CommissionTiming feeMode; + public BigDecimal contract; + public final Integer liquidityB; // LMSR only + public final BigDecimal baseValueD; // Order Book only + public BigDecimal yesPrice = new BigDecimal(0.5); + public BigDecimal noPrice = new BigDecimal(0.5); + public int yesShares, noShares; + public final List trades = new ArrayList<>(); + public final List chart = new ArrayList<>(); + public OrderBook yesBook, noBook; + public final List participants = new ArrayList<>(); + public String resolvedOption; // null unless CLOSED + + public EventData( + final String id, + final int num, + final String name, + final String desc, + final MechanismType type, + final EventStatus status, + final String mm, + final int feePercent, + final CommissionTiming feeMode, + final double contract, + final Integer liquidityB, + final BigDecimal baseValueD) { + this.id = id; + this.num = num; + this.name = name; + this.desc = desc; + this.type = type; + this.status = status; + this.mm = mm; + this.feePercent = feePercent; + this.feeMode = feeMode; + this.contract = BigDecimal.valueOf(contract); + this.liquidityB = liquidityB; + this.baseValueD = baseValueD; + } + + public String getId() { + return id; + } + + public String feeText() { + return feePercent + "% on " + (feeMode == CommissionTiming.ON_PURCHASE ? "purchase" : "close"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EventData eventData = (EventData) o; + return id != null && id.equals(eventData.id); + } + + @Override + public int hashCode() { + return id != null ? id.hashCode() : 0; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/OrderBook.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/OrderBook.java new file mode 100644 index 0000000..230798d --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/OrderBook.java @@ -0,0 +1,16 @@ +package market.guess.ui.desktop.model; + +import java.util.ArrayList; +import java.util.List; + +public final class OrderBook { + public final String optionName; + public final boolean yesOption; + public String last, bid, ask, mid, spread; + public final List rows = new ArrayList<>(); + + public OrderBook(String optionName, boolean yesOption) { + this.optionName = optionName; + this.yesOption = yesOption; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/ParticipantRow.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/ParticipantRow.java new file mode 100644 index 0000000..256a974 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/ParticipantRow.java @@ -0,0 +1,4 @@ +package market.guess.ui.desktop.model; + +public record ParticipantRow( + String user, String tag, int yes, int no, String value, String fees, String pnl) {} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/TradeRow.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/TradeRow.java new file mode 100644 index 0000000..d95b2d6 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/TradeRow.java @@ -0,0 +1,4 @@ +package market.guess.ui.desktop.model; + +public record TradeRow( + int n, String user, String option, boolean yesOption, int shares, String paid) {} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/UserData.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/UserData.java new file mode 100644 index 0000000..62ad526 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/UserData.java @@ -0,0 +1,41 @@ +package market.guess.ui.desktop.model; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +public final class UserData { + public final String name; + + public String getName() { + return name; + } + + public String role; + public BigDecimal balance; + public final boolean blocked; + public final boolean isMm; + public final List balanceHistory = new ArrayList<>(); + public final List events = new ArrayList<>(); + + public UserData(String name, String role, double balance, boolean blocked, boolean isMm) { + this.name = name; + this.role = role; + this.balance = BigDecimal.valueOf(balance); + this.blocked = blocked; + this.isMm = isMm; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserData userData = (UserData) o; + return name != null && name.equalsIgnoreCase(userData.name); + } + + @Override + public int hashCode() { + return name != null ? name.toLowerCase().hashCode() : 0; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/model/UserEventRow.java b/ui-desktop/src/main/java/market/guess/ui/desktop/model/UserEventRow.java new file mode 100644 index 0000000..7c15530 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/model/UserEventRow.java @@ -0,0 +1,4 @@ +package market.guess.ui.desktop.model; + +public record UserEventRow( + String eventId, String eventName, String role, String type, int yes, int no, String pl) {} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/task/InitialLoadTask.java b/ui-desktop/src/main/java/market/guess/ui/desktop/task/InitialLoadTask.java new file mode 100644 index 0000000..e49b0c4 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/task/InitialLoadTask.java @@ -0,0 +1,57 @@ +package market.guess.ui.desktop.task; + +import java.nio.file.Path; +import javafx.concurrent.Task; +import market.guess.api.CatalogContext; +import market.guess.api.LoadResult; + +public final class InitialLoadTask extends Task { + private final CatalogContext catalog; + private final Path path; + + public InitialLoadTask(CatalogContext catalog, Path path) { + this.catalog = catalog; + this.path = path; + } + + @Override + protected LoadResult call() throws Exception { + updateProgress(0, 100); + if (isCancelled()) { + return null; + } + + for (int i = 0; i < 20; i++) { + if (isCancelled()) { + return null; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + if (isCancelled()) { + return null; + } + Thread.currentThread().interrupt(); + break; + } + updateProgress((i + 1) * 5, 100); + } + + if (isCancelled()) { + return null; + } + + var result = catalog.loadEvents(path); + + if (isCancelled()) { + return null; + } + + if (!result.isSuccess()) { + throw new RuntimeException(result.getDetails()); + } + + updateProgress(100, 100); + return result.getData(); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/util/Charts.java b/ui-desktop/src/main/java/market/guess/ui/desktop/util/Charts.java new file mode 100644 index 0000000..54f67ce --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/util/Charts.java @@ -0,0 +1,202 @@ +package market.guess.ui.desktop.util; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; +import javafx.util.StringConverter; +import market.guess.ui.desktop.model.ChartPoint; + +/** Utilities for populating LineChart series and formatting time/value axes. */ +public final class Charts { + private Charts() {} + + public static final long BASE_TIME = + LocalDate.of(2026, 9, 12).atTime(9, 0).atZone(ZoneId.systemDefault()).toEpochSecond(); + + public static double toEpochSecond(double x) { + if (x >= 1_000_000_000) { + return x; + } + return BASE_TIME + (long) (x * 1800); + } + + public static void setSingleSeries( + LineChart chart, String name, List pts) { + configureTimeXAxis(chart, pts); + chart.getData().setAll(List.of(series(name, pts))); + } + + public static void setDualSeries( + LineChart chart, + String name1, + List pts1, + String name2, + List pts2) { + configureTimeXAxis(chart, (pts1 != null && !pts1.isEmpty()) ? pts1 : pts2); + chart.getData().setAll(List.of(series(name1, pts1), series(name2, pts2))); + } + + public static void configureTimeXAxis(LineChart chart, List pts) { + if (!(chart.getXAxis() instanceof NumberAxis xAxis)) return; + + xAxis.setTickMarkVisible(true); + xAxis.setTickLabelsVisible(true); + xAxis.setMinorTickVisible(false); + xAxis.setForceZeroInRange(false); + + if (pts == null || pts.isEmpty()) { + xAxis.setAutoRanging(true); + return; + } + + double min = pts.stream().mapToDouble(p -> toEpochSecond(p.x())).min().orElse(BASE_TIME); + double max = pts.stream().mapToDouble(p -> toEpochSecond(p.x())).max().orElse(BASE_TIME + 3600); + + if (min == max) { + min -= 60; + max += 60; + } + + double range = max - min; + double targetInterval = range / 5.5; + + double[] cleanIntervals = { + 1, 5, 10, 15, 30, 60, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400 + }; + double tickUnit = cleanIntervals[cleanIntervals.length - 1]; + for (double unit : cleanIntervals) { + if (unit >= targetInterval) { + tickUnit = unit; + break; + } + } + + double lower = Math.floor(min / tickUnit) * tickUnit; + double upper = Math.ceil(max / tickUnit) * tickUnit; + if (upper <= max) { + upper += tickUnit; + } + + xAxis.setAutoRanging(false); + xAxis.setLowerBound(lower); + xAxis.setUpperBound(upper); + xAxis.setTickUnit(tickUnit); + + DateTimeFormatter fmt = + (range > 86400) + ? DateTimeFormatter.ofPattern("MM-dd HH:mm").withZone(ZoneId.systemDefault()) + : (range < 300) + ? DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()) + : DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()); + + xAxis.setTickLabelFormatter( + new StringConverter() { + @Override + public String toString(Number n) { + if (n == null) return ""; + try { + return fmt.format(Instant.ofEpochSecond(n.longValue())); + } catch (Exception e) { + return n.toString(); + } + } + + @Override + public Number fromString(String s) { + return 0; + } + }); + } + + /** + * Scales the Y axis dynamically with sensible headroom and round tick marks so values (like + * balances > $1,500) never get cut off at the top or bottom. + */ + public static void scaleYAxis(NumberAxis yAxis, List pts, Double currentVal) { + if (yAxis == null) return; + if ((pts == null || pts.isEmpty()) && currentVal == null) { + yAxis.setAutoRanging(true); + return; + } + + double min = + (pts != null && !pts.isEmpty()) + ? pts.stream().mapToDouble(ChartPoint::y).min().orElse(0.0) + : (currentVal != null ? currentVal : 0.0); + double max = + (pts != null && !pts.isEmpty()) + ? pts.stream().mapToDouble(ChartPoint::y).max().orElse(100.0) + : (currentVal != null ? currentVal : 100.0); + + if (currentVal != null) { + min = Math.min(min, currentVal); + max = Math.max(max, currentVal); + } + + if (max <= 0 && min <= 0) { + double absMax = Math.abs(min); + double unit = niceNum(absMax / 4.0, true); + if (unit <= 0) unit = 50; + double lower = Math.floor(min / unit) * unit - unit; + yAxis.setAutoRanging(false); + yAxis.setLowerBound(lower); + yAxis.setUpperBound(0.0); + yAxis.setTickUnit(unit); + return; + } + + double targetMax = max > 0 ? max * 1.15 : 100.0; + double targetMin = min < 0 ? min * 1.20 : 0.0; + double range = targetMax - targetMin; + double unit = niceNum(range / 5.0, true); + if (unit <= 0) unit = 50; + + double lower = targetMin < 0 ? Math.floor(targetMin / unit) * unit : 0.0; + double upper = Math.ceil(targetMax / unit) * unit; + if (upper <= max) { + upper += unit; + } + + yAxis.setAutoRanging(false); + yAxis.setLowerBound(lower); + yAxis.setUpperBound(upper); + yAxis.setTickUnit(unit); + } + + private static double niceNum(double range, boolean round) { + if (range <= 0) return 1.0; + double exponent = Math.floor(Math.log10(range)); + double fraction = range / Math.pow(10, exponent); + double niceFraction; + + if (round) { + if (fraction < 1.5) niceFraction = 1.0; + else if (fraction < 3.0) niceFraction = 2.0; + else if (fraction < 7.0) niceFraction = 5.0; + else niceFraction = 10.0; + } else { + if (fraction <= 1.0) niceFraction = 1.0; + else if (fraction <= 2.0) niceFraction = 2.0; + else if (fraction <= 5.0) niceFraction = 5.0; + else niceFraction = 10.0; + } + + return niceFraction * Math.pow(10, exponent); + } + + private static XYChart.Series series(String name, List pts) { + var s = new XYChart.Series(); + s.setName(name); + if (pts != null) { + for (var p : pts) { + s.getData().add(new XYChart.Data<>(toEpochSecond(p.x()), p.y())); + } + } + return s; + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/util/Format.java b/ui-desktop/src/main/java/market/guess/ui/desktop/util/Format.java new file mode 100644 index 0000000..02380bc --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/util/Format.java @@ -0,0 +1,21 @@ +package market.guess.ui.desktop.util; + +import java.math.BigDecimal; + +/** Pure formatting helpers for monetary amounts and probabilities. */ +public final class Format { + private Format() {} + + public static String money(BigDecimal n) { + if (n == null) return "$0.00"; + return (n.signum() < 0 ? "-$" : "$") + String.format("%.2f", n.abs()); + } + + public static String money(double n) { + return money(BigDecimal.valueOf(n)); + } + + public static String prob(double n) { + return String.format("%.2f", n); + } +} diff --git a/ui-desktop/src/main/java/market/guess/ui/desktop/util/Views.java b/ui-desktop/src/main/java/market/guess/ui/desktop/util/Views.java new file mode 100644 index 0000000..50d9c36 --- /dev/null +++ b/ui-desktop/src/main/java/market/guess/ui/desktop/util/Views.java @@ -0,0 +1,41 @@ +package market.guess.ui.desktop.util; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import javafx.css.PseudoClass; +import javafx.fxml.FXMLLoader; +import javafx.scene.Node; +import javafx.scene.Parent; +import market.guess.ui.desktop.AppView; + +/** Shared JavaFX view mechanics, pseudo-class management, and FXML root loading. */ +public final class Views { + private Views() {} + + public static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + + public static void setSelected(Node node, boolean selected) { + if (node != null) { + node.pseudoClassStateChanged(SELECTED, selected); + } + } + + public static void loadRoot(Parent root, String fxmlName) { + URL res = AppView.class.getResource(fxmlName); + if (res == null) { + res = AppView.class.getResource("/market/guess/ui/desktop/" + fxmlName); + } + if (res == null) { + throw new IllegalArgumentException("Cannot find FXML resource: " + fxmlName); + } + FXMLLoader loader = new FXMLLoader(res); + loader.setRoot(root); + loader.setController(root); + try { + loader.load(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load " + fxmlName, e); + } + } +} -- cgit v1.2.3