Bin
2025-12-16 9e0b2ba2c317b1a86212f24cbae3195ad1f3dbfa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
import { inject, observer } from "mobx-react";
import { types } from "mobx-state-tree";
 
import Ranker from "../../components/Ranker/Ranker";
import Registry from "../../core/Registry";
import Tree from "../../core/Tree";
import Types from "../../core/Types";
import { AnnotationMixin } from "../../mixins/AnnotationMixin";
import { ReadOnlyControlMixin } from "../../mixins/ReadOnlyMixin";
import { guidGenerator } from "../../utils/unique";
import Base from "./Base";
 
// column to display items from original List, when there are no default Bucket
const ORIGINAL_ITEMS_KEY = "_";
 
/**
 * The `Ranker` tag is used to rank items in a `List` tag or pick relevant items from a `List`, depending on using nested `Bucket` tags.
 * In simple case of `List` + `Ranker` tags the first one becomes interactive and saved result is a dict with the only key of tag's name and with value of array of ids in new order.
 * With `Bucket`s any items from the `List` can be moved to these buckets, and resulting groups will be exported as a dict `{ bucket-name-1: [array of ids in this bucket], ... }`
 * By default all items will sit in `List` and will not be exported, unless they are moved to a bucket. But with `default="true"` parameter you can specify a bucket where all items will be placed by default, so exported result will always have all items from the list, grouped by buckets.
 * Columns and items can be styled in `Style` tag by using respective `.htx-ranker-column` and `.htx-ranker-item` classes. Titles of columns are defined in `title` parameter of `Bucket` tag.
 * Note: When `Bucket`s used without `default` param, the original list will also be stored as "_" named column in results, but that's internal value and this may be changed later.
 * @example
 * <!-- Visual appearance can be changed via Style tag with these predefined classnames -->
 * <View>
 *   <Style>
 *     .htx-ranker-column { background: cornflowerblue; }
 *     .htx-ranker-item { background: lightgoldenrodyellow; }
 *   </Style>
 *   <List name="results" value="$items" title="Search Results" />
 *   <Ranker name="rank" toName="results" />
 * </View>
 * @example
 * <!-- Example task data for Ranker tag -->
 * {
 *   "items": [
 *     { "id": "blog", "title": "10 tips to write a better function", "body": "There is nothing worse than being left in the lurch when it comes to writing a function!" },
 *     { "id": "mdn", "title": "Arrow function expressions", "body": "An arrow function expression is a compact alternative to a traditional function" },
 *     { "id": "wiki", "title": "Arrow (computer science)", "body": "In computer science, arrows or bolts are a type class..." }
 *   ]
 * }
 * @example
 * <!-- Example result for Ranker tag -->
 * {
 *   "from_name": "rank",
 *   "to_name": "results",
 *   "type": "ranker",
 *   "value": { "ranker": { "rank": ["mdn", "wiki", "blog"] } }
 * }
 * @example
 * <!-- Example of using Buckets with Ranker tag -->
 * <View>
 *   <List name="results" value="$items" title="Search Results" />
 *   <Ranker name="rank" toName="results">
 *     <Bucket name="best" title="Best results" />
 *     <Bucket name="ads" title="Paid results" />
 *   </Ranker>
 * </View>
 * @example
 * <!-- Example result for Ranker tag with Buckets; data is the same -->
 * {
 *   "from_name": "rank",
 *   "to_name": "results",
 *   "type": "ranker",
 *   "value": { "ranker": {
 *     "best": ["mdn"],
 *     "ads": ["blog"]
 *   } }
 * }
 * @name Ranker
 * @meta_title Ranker Tag allows you to rank items in a List or, if Buckets are used, pick relevant items from a List
 * @meta_description Customize Label Studio by sorting results for machine learning and data science projects.
 * @param {string} name    Name of the element
 * @param {string} toName  List tag name to connect to
 */
const Model = types
  .model({
    type: "ranker",
    toname: types.maybeNull(types.string),
    collapsible: types.optional(types.boolean, true),
 
    // @todo allow Views inside: ['bucket', 'view']
    children: Types.unionArray(["bucket"]),
  })
  .views((self) => ({
    get list() {
      const list = self.annotation.names.get(self.toname);
 
      return list.type === "list" ? list : null;
    },
    get buckets() {
      return Tree.filterChildrenOfType(self, "BucketModel");
    },
    /**
     * rank mode: tag's name
     * pick mode: undefined
     * group mode: name of the Bucket with default=true
     * @returns {string | undefined}
     */
    get defaultBucket() {
      return self.buckets.length > 0 ? self.buckets.find((b) => b.default)?.name : self.name;
    },
    get rankOnly() {
      return !self.buckets.length;
    },
    /** @returns {Array<{ id: string, title: string }>} */
    get columns() {
      if (!self.list) return [];
      if (self.rankOnly) return [{ id: self.name, title: self.list.title }];
 
      const columns = self.buckets.map((b) => ({ id: b.name, title: b.title ?? "" }));
 
      if (!self.defaultBucket) columns.unshift({ id: ORIGINAL_ITEMS_KEY, title: self.list.title });
 
      return columns;
    },
  }))
  .views((self) => ({
    get dataSource() {
      const data = self.list?._value;
      const items = self.list?.items;
      const ids = Object.keys(items);
      const columns = self.columns;
      /** @type {Record<string, string[]>} */
      const columnStubs = Object.fromEntries(self.columns.map((c) => [c.id, []]));
      /** @type {Record<string, string[]>} */
      const result = self.result?.value.ranker;
      let itemIds = {};
 
      if (!data) return [];
      if (!result) {
        itemIds = { ...columnStubs, [self.defaultBucket ?? ORIGINAL_ITEMS_KEY]: ids };
      } else {
        itemIds = { ...columnStubs, ...result };
 
        // original list is displayed, but there are no such column in result,
        // so create it from results not groupped into buckets;
        // also if there are unknown columns in result they'll go there too.
        if (!self.defaultBucket) {
          const columnNames = self.columns.map((c) => c.id);
          // all items in known columns, including original list (_)
          const selected = Object.entries(result)
            .filter(([key]) => columnNames.includes(key))
            .flatMap(([_, values]) => values);
          // all undistributed items or items from unknown columns
          const left = ids.filter((id) => !selected.includes(id));
 
          if (left.length) {
            // there are might be already some items in result
            itemIds[ORIGINAL_ITEMS_KEY] = [...(itemIds[ORIGINAL_ITEMS_KEY] ?? []), ...left];
          }
        }
      }
 
      return { items, columns, itemIds };
    },
    get result() {
      return self.annotation?.results.find((r) => r.from_name === self);
    },
  }))
  .actions((self) => ({
    createResult(data) {
      self.annotation.createResult({}, { ranker: data }, self, self.list);
    },
 
    updateResult(newData) {
      // check if result exists already, since only one instance of it can exist at a time
      if (self.result) {
        self.result.setValue(newData);
      } else {
        self.createResult(newData);
      }
    },
 
    // Create result on submit if it doesn't exist
    beforeSend() {
      if (!self.list) return;
 
      // @todo later we will most probably remove _ bucket from exported result
      if (self.result) return;
 
      const ids = Object.keys(self.list?.items);
      // empty array for every column
      const data = Object.fromEntries(self.columns.map((c) => [c.id, []]));
 
      // List items should also be stored at the beginning for consistency, we add them to result
      data[self.defaultBucket ?? ORIGINAL_ITEMS_KEY] = ids;
 
      self.createResult(data);
    },
  }));
 
const RankerModel = types.compose("RankerModel", Base, AnnotationMixin, Model, ReadOnlyControlMixin);
 
const HtxRanker = inject("store")(
  observer(({ item }) => {
    const data = item.dataSource;
 
    if (!data) return null;
 
    return (
      <Ranker
        inputData={data}
        handleChange={item.updateResult}
        readonly={item.isReadOnly()}
        collapsible={item.collapsible}
      />
    );
  }),
);
 
/**
 * Simple container for items in `Ranker` tag. Can be used to group items in `List` tag.
 * @name Bucket
 * @subtag
 * @param {string} name        Name of the column; used as a key in resulting data
 * @param {string} title       Title of the column
 * @param {boolean} [default]  This Bucket will be used to display results from `List` by default; see `Ranker` tag for more details
 */
const BucketModel = types.model("BucketModel", {
  id: types.optional(types.identifier, guidGenerator),
  type: "bucket",
  name: types.string,
  title: types.maybeNull(types.string),
  default: types.optional(types.boolean, false),
});
 
const HtxBucket = inject("store")(
  observer(({ item }) => {
    return <h1>{item.name}</h1>;
  }),
);
 
Registry.addTag("ranker", RankerModel, HtxRanker);
Registry.addTag("bucket", BucketModel, HtxBucket);
Registry.addObjectType(RankerModel);
 
export { BucketModel, HtxRanker, RankerModel };